---
title: React clients
summary: Stream a run or a session into a React app through your backend.
canonical: https://docs.caveman.so/docs/agent-sdk/react
license: Apache-2.0
capability: agent-sdk
updated: 2026-09-16T21:32:40-07:00
basis: inferred
---

# React clients

> Stream a run or a session into a React app through your backend.
`@caveman-ai/react` streams an agent run, or a whole session, into a React component. Both hooks take a same-origin path in your own app rather than the agent server's origin, and both leave the token behind: the bearer the [agent server](/docs/agent-sdk/deploy) requires spends money and returns model output, so it belongs on your server.

```bash
npm install <checkout>/packages/react
```

Unreleased: this page documents `@caveman-ai/agent` 0.2.0 from source at commit 311be40 in caveman-ai/agent-sdk. Build it from the checkout as described on [Install](/docs/agent-sdk/install). `@caveman-ai/react` has no build step of its own; install it from the same checkout, where `<checkout>` is the path you cloned into.

```tsx
"use client";
import { useSession } from "@caveman-ai/react";

export function Case({ id }: { id: string }) {
  const { events, send, cancel, status, gap } = useSession({
    api: "/api/agent",
    sessionId: id,
    transport: "sse",
  });

  return (
    <div>
      {gap && <p role="status">Earlier history was evicted. The transcript is complete from here.</p>}
      <p aria-live="polite" aria-busy={status === "streaming"}>
        {events.filter((e) => e.kind === "delta.text").map((e) => e.text).join("")}
      </p>
      <button onClick={() => send("check again", { author: "Ada" })}>Ask</button>
      <button onClick={() => cancel()} disabled={status !== "streaming"}>Stop</button>
    </div>
  );
}
```

`events` are the Pebble frames the server sends, in order, across every run in the session. `status` is `connecting`, `streaming`, `complete`, `error`, or `cancelled`.

## The routes your app has to proxy

`api` is a base path your app serves. Each route forwards to the agent server with the bearer attached, and each one is where your app decides who may spend money.

```ts
// app/api/agent/sessions/[id]/messages/route.ts
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  return fetch(`${process.env.AGENT_URL}/sessions/${encodeURIComponent(id)}/messages`, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      authorization: `Bearer ${process.env.AGENT_TOKEN}`,
    },
    body: await request.text(),
  });
}
```

```ts
// app/api/agent/sessions/[id]/events/route.ts
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const upstream = await fetch(
    `${process.env.AGENT_URL}/sessions/${encodeURIComponent(id)}/events`,
    {
      headers: {
        authorization: `Bearer ${process.env.AGENT_TOKEN}`,
        ...(request.headers.get("last-event-id")
          ? { "last-event-id": request.headers.get("last-event-id")! }
          : {}),
      },
    },
  );
  return new Response(upstream.body, {
    status: upstream.status,
    headers: {
      "content-type": "text/event-stream",
      "cache-control": "no-cache, no-transform",
      "x-accel-buffering": "no",
    },
  });
}
```

Both handlers take `params` as a promise, which is the Next 15 and later signature. Pass `last-event-id` through, and a reconnect resumes where it stopped; drop it and a reconnect replays the session from the beginning. `cancel()` needs `DELETE /sessions/:id` proxied the same way.

`transport: "ws"` swaps the event route for the `/ws` upgrade, and then `send` goes over the socket. Your proxy either adds `Authorization` on the upgrade or terminates the socket itself. The server does accept the bearer as a `cave-bearer.` subprotocol, which exists for clients that cannot set headers; in a browser a subprotocol is as public as the bundle carrying it.

## Reconnecting

On `sse`, `EventSource` reconnects by itself and resumes from `Last-Event-ID`, so every frame arrives once. On `ws` the hook reconnects, because a browser socket leaves that to its caller: it reopens after 250 ms with `?lastEventId=<seq>` and the server answers with exactly the span that was missed. `cancel()` or unmounting stops that for good.

When the requested sequence is older than the server's retained window, the server sends a gap notice before it streams. `gap` becomes `true` and `lastGap` carries `{ requestedSeq, earliestSeq }`. History before that point was evicted, the transcript is complete from there on, and `status` stays live rather than turning into an error. The run journal remains the record of what happened.

## One run instead of a session

`useAgent` streams a single run submitted through `POST /runs`, for a background job or a one-shot answer rather than a conversation. It uses two routes under the same `api` base, proxied the same way:

```ts
// app/api/agent/runs/route.ts
export async function POST(request: Request) {
  return fetch(`${process.env.AGENT_URL}/runs`, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      authorization: `Bearer ${process.env.AGENT_TOKEN}`,
    },
    body: await request.text(),
  });
}
```

```ts
// app/api/agent/runs/[id]/events/route.ts
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const upstream = await fetch(`${process.env.AGENT_URL}/runs/${encodeURIComponent(id)}/events`, {
    headers: { authorization: `Bearer ${process.env.AGENT_TOKEN}` },
  });
  return new Response(upstream.body, {
    status: upstream.status,
    headers: {
      "content-type": "text/event-stream",
      "cache-control": "no-cache, no-transform",
      "x-accel-buffering": "no",
    },
  });
}
```

The hook mints the run id with `crypto.randomUUID()` and posts `{runId, input}`, so your route forwards the body unchanged.

```tsx
"use client";
import { useAgent } from "@caveman-ai/react";

export function Ask() {
  const { text, tools, status, usage, error, submit } = useAgent({ api: "/api/agent" });

  return (
    <div>
      <button onClick={() => submit("how many items are queued?")} disabled={status === "streaming"}>
        Ask
      </button>
      {tools.map((tool) => <div key={tool.id}>{tool.name}: {tool.status}</div>)}
      <p aria-live="polite" aria-busy={status === "streaming"}>{text}</p>
      {error && <p role="alert">{error.message}</p>}
      {usage && (
        <small>
          {usage.in + usage.out} tokens,{" "}
          {usage.costUsd === null ? "cost unknown" : `$${usage.costUsd.toFixed(4)}`}
        </small>
      )}
    </div>
  );
}
```

| Field | What it holds |
|---|---|
| `status` | `idle`, `streaming`, `complete`, `error`, `detached` |
| `text` | Assistant text so far |
| `thinking` | Reasoning deltas, when the model emits them |
| `tools` | `{ id, name, args, status, detail }` per tool call; `args` is the truncated wire summary |
| `usage` | `{ in, out, cacheRead, cacheWrite, costUsd }` |
| `route` | `{ model, reason }`, the model actually chosen |
| `stopReason` | `end_turn`, `budget_paused`, `interrupted`, or `error` |
| `error` | `{ message, retryable }` |
| `gap` | `{ error, requestedSeq, earliestSeq }` when a missed span could not be replayed |
| `runId` | The id this run was submitted under |
| `submit(input)` | Starts a run, resolves with its id |
| `watch(runId)` | Attaches to a run this hook did not submit |
| `stopWatching()` | Closes this view and leaves the run going |

A run outlives the tab that started it, and `submit` resolves with the id before the first token arrives, so a reload can reattach:

```tsx
useEffect(() => {
  const resuming = sessionStorage.getItem("runId");
  if (resuming) watch(resuming);
}, [watch]);
```

`watch` replays the run from its first event, so the state it rebuilds is the state an uninterrupted stream would have produced. Reattaching is yours to call, because the decision between reattaching and starting clean lives in your app rather than in a hook.

`stopWatching()` closes the stream and leaves the agent working. Cancelling is `DELETE /runs/:id`, which your own code calls when you want it.

## Verify and undo

With the routes in place, one request proves the proxy path end to end:

```bash
curl -s -X POST localhost:3000/api/agent/runs -H "content-type: application/json" \
  -d '{"runId":"smoke-1","input":"Where is order A-123?"}'
```

```console
{"runId":"smoke-1","status":"running"}
```

Captured through the `POST /api/agent/runs` route above, forwarding to an agent server running a scripted model. A `401` means the route forwarded without the bearer, and a `404` means the path under `api` is wrong. To roll back, delete the route handlers and the `useAgent` or `useSession` call; the agent server keeps serving and every run stays journaled.

## Limits

`usage.costUsd` is `null` when any message in the turn was unpriced. That means unknown, not zero, and the hook hands you no priced subtotal dressed up as a total. Token counts stay exact either way. Render the null case.

`useAgent` has no `messages` array, because the agent server has no thread: one agent, one input, one run, and a run is not a reply to the last one. Keep your own history if you want one, or use `useSession`, where the conversation is real and lives on the server.

The event window is in memory and bounded. A run that settled long ago, or one whose events an instance restart discarded, has no stream left to attach to and surfaces as a closed stream. `GET /runs/:id` is the authoritative outcome.

React 18 or newer, and both hooks are client components.
