# 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.*

## 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.evidence.map((e) => (
))}
Expires in {minutesLeft(cp.expiresAt)} min
);
}
“`
The lifecycle matters as much as the layout. When a checkpoint arrives while the app is backgrounded, you drop a **local notification with actions** (Approve/Reject) wired to the same handlers, and you persist the checkpoint to disk so it survives a cold start. On resume, the card list reconciles against the server: anything expired is greyed out, anything already resolved elsewhere is removed. The golden rule is **never let a tap that mutates production data feel like a throwaway interaction** — for any `destructive` tier, the Approve button triggers a biometric challenge and a short hold-to-confirm, so a pocket-tap can’t authorize an irreversible write.
## 4. Performance: Optimizing React-Native State for High-Frequency Agent Logs
Agent logs are a firehose. A busy run emits dozens of progress events per second, and if each one triggers a React state update you will drop frames on mid-tier Android hardware within seconds. The fix is not “optimize later” — it’s a set of three disciplines applied from the first commit.
**First, batch at the frame boundary.** Never `setState` per event. Buffer incoming events and flush once per animation frame with `requestAnimationFrame`, collapsing multiple updates to the same call ID into the latest value. This turns a thousand dispatches per second into sixty.
“`ts
const buffer = useRef(new Map
const raf = useRef(0);
const enqueue = (callId: string, pct: number) => {
buffer.current.set(callId, pct); // last-write-wins per call
if (!raf.current) raf.current = requestAnimationFrame(flush);
};
const flush = () => {
buffer.current.forEach((pct, id) => store.getState().setProgress(id, pct));
buffer.current.clear(); raf.current = 0;
};
“`
**Second, keep the store external and flat.** React state is for *what’s rendered*; the log itself lives in a plain observable store keyed by call ID (`Record
**Third, bound everything.** Virtualize the visible log with `FlatList` and fixed row heights. Cap completed calls with a ring buffer — keep the last 2,000, aggregate older ones into counters — because nobody scrolls 20,000 rows on a phone, and memory on mobile is not free. Animate with `useNativeDriver` so progress and opacity changes run on the UI thread, not the JS thread that’s also parsing your event stream. Finally, throttle the *visual* update rate independently of the *data* rate: coalesce progress into ~15 Hz for display while keeping every event in the audit buffer. The operator perceives smoothness; the audit trail keeps fidelity.
The payoff: a phone that renders a live agent run at 60 fps, keeps the JS thread free for approvals, and survives an hour of streaming without a thermal throttle. Performance here isn’t polish — it’s the difference between an approver trusting the interface and closing the app.
## 5. Security: Biometric Approval and OAuth2 PKCE on Mobile Devices
A mobile client that can authorize production writes is a high-value target, so the security model has to hold up to a lost device and a hostile network. Two mechanisms carry the weight.
**OAuth2 with PKCE (Proof Key for Code Exchange).** Mobile apps are public clients — there’s no safe place to hide a client secret, so the authorization-code flow alone is insufficient. PKCE binds the token exchange to the same app instance that started it. The app generates a random `code_verifier`, sends its SHA-256 hash as the `code_challenge` with the authorization request, and proves possession by sending the raw verifier at token exchange. A malicious app that intercepts the redirect can’t complete the exchange without the verifier it never saw.
“`ts
import * as AuthSession from “expo-auth-session”;
import * as Crypto from “expo-crypto”;
const verifier = base64url(await Crypto.getRandomBytesAsync(32));
const challenge = base64url(await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256, verifier, { encoding: “base64” }));
const auth = await AuthSession.startAsync({
authUrl: `${ISSUER}/authorize?response_type=code&client_id=${CLIENT_ID}` +
`&redirect_uri=${REDIRECT}&scope=${SCOPES}` +
`&code_challenge=${challenge}&code_challenge_method=S256`,
returnUrl: REDIRECT,
});
// Exchange auth.params.code + verifier for tokens, then store in SecureStore.
“`
Tokens live in the OS keychain (`expo-secure-store` / Keychain / Keystore), never in `AsyncStorage`, and access tokens stay short-lived with silent refresh. Bind the redirect URI to a verified app link so an attacker can’t hijack the callback.
**Biometric approval as a second signal.** OAuth answers “who is this device?” Biometrics answer “is the human present *right now*?” Those are different questions, and for destructive checkpoints you want both. Before a high-tier approval is accepted, the app runs a local biometric prompt (`expo-local-authentication` / `BiometricPrompt`), and the resulting approval is sent with a nonce the server binds to that checkpoint and a timestamp. The server rejects stale nonces and replays. Critically, the approval payload is signed with a key stored in the hardware-backed keystore, so a rooted device or a replayed request can’t forge consent.
“`ts
const ok = await LocalAuthentication.authenticateAsync({
promptMessage: `Approve ${cp.tool}`,
cancelLabel: “Cancel”,
disableDeviceFallback: true, // no PIN bypass for destructive writes
});
if (ok.success) {
const sealed = await SecureStore.getItemAsync(“signing_key”); // hardware-backed
await api.approve(cp.id, { nonce: cp.nonce, sig: await sign(sealed, cp.nonce) });
}
“`
Layer in the boring-but-essential hygiene: certificate pinning on the WebSocket, jailbreak/root detection that downgrades the client to view-only, short-lived server-side checkpoint tokens, and an audit record of *which device, which biometric, which nonce* authorized each action. In a regulated deployment, that audit trail is the whole reason the mobile approval exists.
## 6. Code Walkthrough: A React-Native ‘Approval Hook’
Here is the whole approval flow in one hook — pending checkpoints from the socket, biometric gating, optimistic UI, and durable rollback. It composes the pieces from the previous sections.
“`tsx
import { useCallback, useEffect, useReducer } from “react”;
import * as LocalAuthentication from “expo-local-authentication”;
type Pending = Record
type Action =
| { type: “upsert”; cp: Checkpoint }
| { type: “remove”; id: string }
| { type: “resolve”; id: string; status: “approved” | “rejected” };
function reducer(state: Pending, a: Action): Pending {
switch (a.type) {
case “upsert”: return { …state, [a.cp.id]: a.cp };
case “remove”: { const { [a.id]: _, …rest } = state; return rest; }
default: return state; // resolution is optimistic elsewhere
}
}
export function useApprovals(socket: AgentSocket) {
const [pending, dispatch] = useReducer(reducer, {});
// Stream checkpoints into the reducer; replayed events survive reconnect.
useEffect(() => {
const off = socket.on(“event”, (ev) => {
if (ev.name === “checkpoint/opened”) dispatch({ type: “upsert”, cp: ev.data });
if (ev.name === “checkpoint/closed”) dispatch({ type: “remove”, id: ev.data.id });
});
return off; // unsubscribe on unmount
}, [socket]);
const approve = useCallback(
async (cp: Checkpoint) => {
if (cp.requiresBiometric) {
const ok = await LocalAuthentication.authenticateAsync({
promptMessage: `Approve ${cp.tool}`,
disableDeviceFallback: true,
});
if (!ok.success) return { ok: false, reason: “auth_cancelled” as const };
}
// Optimistic: hide the card immediately, reconcile on server ack.
dispatch({ type: “remove”, id: cp.id });
try {
await socket.request(“checkpoint/approve”, {
id: cp.id, nonce: cp.nonce, at: Date.now(),
});
return { ok: true as const };
} catch (e) {
dispatch({ type: “upsert”, cp }); // rollback: put the card back
return { ok: false, reason: “server_error” as const };
}
},
[socket],
);
const reject = useCallback(
async (cp: Checkpoint) => {
dispatch({ type: “remove”, id: cp.id });
await socket.request(“checkpoint/reject”, { id: cp.id });
},
[socket],
);
return { pending: Object.values(pending), approve, reject };
}
“`
Four things earn their place here. **The reducer owns pending state, not the socket** — so a reconnect that replays `checkpoint/opened` converges rather than duplicating. **Biometric gating is inside the action**, not scattered across call sites, so no button can bypass it. **The optimistic removal plus rollback** keeps the UI responsive on a slow mobile network while never lying about a failed approval — if the server rejects, the card returns. And **`disableDeviceFallback`** ensures a destructive approval can’t be satisfied by a PIN; the human presence signal has to be real. The list rendering then just maps `pending` to `CheckpointCard`, sorted by risk tier then age — and each card’s buttons are wired straight to `approve` and `reject`.
## Ship the Cockpit, Not Just the Agent
The pattern generalizes cleanly: terminate SSE in a gateway and fan it out over a resilient WebSocket, model checkpoints as first-class decision objects, render them as risk-ranked cards, keep high-frequency logs in a batched external store, and gate destructive approvals behind PKCE-scoped tokens *and* hardware-backed biometrics. None of it is exotic — but it is the difference between an agent that can only run under a watchful desk and one that ships trusted decisions from a phone in an approver’s pocket.
That’s exactly the kind of mobile control surface OnlineCloud builds. We’re a US-based engineering studio focused on MCP, autonomous agents, and applied ML — and we wire human checkpoints, streamable transports, and auditable auth boundaries into real systems on top of Oracle, MS SQL, Snowflake, and your CRM. Senior engineers only, no offshore handoffs, production in 6–10 weeks.
**[Tell us about your agent →](mailto:contact@onlinecloud.dev)** — free initial consultation, response within 24 hours. Or explore more at **[onlinecloud.dev](https://www.onlinecloud.dev/)**.
Leave a Reply