Building a React-Native Interface for Agent Interaction & Checkpoints

Written by

in

# Building a React-Native Interface for Agent Interaction & Checkpoints

*Why the hardest part of shipping a human-in-the-loop agent isn’t the model or the server — it’s the approval flow that has to work when your reviewer is standing in line for coffee.*

![OnlineCloud AI engineering solutions](https://www.onlinecloud.dev/assets/hero-company-V8Xps1Ae.png)

## 1. The UX Challenge of Mobile-First Agent Monitoring

A desktop dashboard is a forgiving place to put an agent’s control loop. You have screen real estate for a decision tree, a scrollable log, a diff panel, and a modal — all at once. A phone gives you 390 points of width, an intermittent radio, a battery budget, and a user who is context-switching between your agent and three other apps. When you move human-in-the-loop tooling to mobile, every assumption the web version made quietly breaks.

Start with the core tension. Agent monitoring is a *pull* activity on desktop — an operator sits and watches. On mobile it is a *push* activity — the operator is away, and the system has to reach them at the exact moment a checkpoint opens, with just enough context to make a decision in under thirty seconds. That inverts the whole information hierarchy. A mobile checkpoint cannot be “here is the run, go find the pending approval.” It has to be “here is the single decision blocking progress, the evidence for it, and two buttons.” Everything else is secondary.

The second challenge is that mobile sessions are hostile to long-lived state. iOS suspends your socket when the app backgrounds, kills it on memory pressure, and severs network on the subway. A web dashboard can assume a persistent connection and a visible tab; a mobile client must assume the connection dies every few minutes and rebuild its view from a durable source of truth on every resume. That single constraint — *assume disconnection* — reshapes the architecture more than any UI decision.

The third is trust under compression. On desktop, an approver can expand a JSON payload, cross-check a call path, and take their time. On mobile, you are asking someone to authorize, say, a `create_refund` for $4,200 on a screen the size of a business card, using a thumb. If the interface doesn’t make the *blast radius* unmistakable — what tool, what arguments, what it touches — you’ve built a rubber-stamp machine. The entire design goal of a mobile agent interface is to make the risky decision legible without making it casual. That is a UX problem first and an engineering problem second, and it is the problem this post walks through end to end.

## 2. Architecture: SSE-over-WebSocket for Real-Time Mobile Updates

Agent servers speak a stream of notifications: tool-call lifecycles, progress events, and checkpoint requests. The idiomatic transport is SSE — text/event-stream, one-directional, dead simple, and exactly what MCP recommends for streamable transports. But SSE has real problems on mobile. `EventSource` on React Native doesn’t exist in the core runtime, it can’t attach custom headers (so no `Authorization` header), and a few RN networking stacks buffer `text/event-stream` in ways that break incremental delivery.

So the pragmatic pattern is **SSE semantics over a WebSocket transport** — the classic “SSE-over-WebSocket” bridge. You keep a single duplex WebSocket to a small gateway, and that gateway multiplexes the SSE-style event stream the server already produces. You get the ergonomics of SSE (ordered, server-push, named events) with the control a WebSocket gives you on mobile: custom headers, a ping/pong heartbeat, and a transport that survives proxies and mobile NAT far better than a bare long-lived HTTP response.

“`ts
// gateway.ts — terminate SSE on the server, fan out over a duplex WS to mobile.
// The RN client never parses text/event-stream; it receives framed events.
type Frame =
| { t: “hello”; lastEventId: string }
| { t: “event”; id: string; name: string; data: unknown }
| { t: “ping”; ts: number }
| { t: “resume”; from: string };

export function bridge(sse: Response, ws: WebSocket) {
const reader = sse.body!.getReader();
const dec = new TextDecoder();
let buf = “”, lastId = “”;
// Replay from the client’s lastEventId so a resumed socket never loses checkpoints.
const pump = async () => {
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf(“\n\n”)) !== -1) {
const block = buf.slice(0, i); buf = buf.slice(i + 2);
const ev = parseSse(block); // { id, event, data }
if (ev.id) lastId = ev.id;
ws.send(JSON.stringify({ t: “event”, id: lastId, …ev }));
}
}
};
pump().catch(() => ws.close(1011, “upstream dropped”));
}
“`

Two details make this production-safe. **Heartbeats keep the socket alive through carrier NAT** — a `{ t: “ping” }` every 20 seconds prevents the 60-second idle timeout that silently kills mobile WebSockets, and the client answers so the gateway can detect a dead peer. **Event IDs make resume cheap.** Every event carries a monotonic ID; on reconnect the client sends its `lastEventId`, and the gateway replays from a short server-side ring buffer before switching to live. This is the *assume disconnection* principle in practice: the client is stateless, the stream is replayable, and a subway tunnel costs you latency, not correctness.

The client side stays deliberately thin. One `AgentSocket` class owns reconnection with exponential backoff plus jitter, exposes an async iterator of frames, and forwards checkpoint events into a durable queue (more on that in section 3). Because the transport is a WebSocket with headers, the gateway can authenticate the upgrade request with the same bearer token the rest of the API uses — something bare `EventSource` makes awkward on mobile.

## 3. The Interaction Model: Checkpoint Cards for Human Approval on the Go

A checkpoint is not a notification; it’s a *decision object*. Model it that way and the UI falls out. The server emits a checkpoint with everything the approver needs and nothing they don’t: the tool name, a normalized summary of arguments, the call path that produced it, a risk tier, and an expiry. Your mobile UI renders that object as a **card** — one card per pending decision, ranked by risk and age, swipeable, and fully actionable from the lock screen or the home screen.

“`ts
export type Checkpoint = {
id: string;
tool: string;
riskTier: “read” | “write” | “destructive”;
summary: string; // “Refund $4,200 to order #8812”
evidence: { label: string; value: string }[];
parentChain: string[]; // the tool calls that led here
expiresAt: string;
requiresBiometric: boolean;
};
“`

The card itself follows three rules. **Summarize, don’t dump.** A `summary` string computed server-side beats a raw JSON blob on a phone; a `pre[].scroll` view of 400 lines of arguments is a desktop affordance. **Weight by risk, not recency.** A destructive write should never sit below three read-only confirmations just because it arrived later — sort by tier first, then age. **Make the terminal states explicit.** Approve, Reject, and a third that matters more than people expect: **”escalate to desktop.”** Some decisions genuinely shouldn’t be made on a phone, and giving the approver a dignified exit is how you avoid rubber-stamping.

“`tsx
export function CheckpointCard({ cp, onApprove, onReject, onEscalate }: Props) {
return (


{cp.tool}


{cp.summary}
{cp.evidence.map((e) => (

))}

Expires in {minutesLeft(cp.expiresAt)} min


Comments

Leave a Reply

Your email address will not be published. Required fields are marked *