Guides
Stream a camelRun agent to the browser
Let a browser read one camelRun agent directly (messages as they stream, tool progress, inputs, history) with a short-lived token, not your API key.
View as Markdown · Docs index for coding agents
A browser can read an agent directly from camelRun (its messages as they stream, the running turn, tool progress, waiting inputs and older history) with a browser token your server mints for each user after its own access checks. The browser never holds your API key or the agent's token, and your server relays nothing. For how it fits a chat in your app, see streaming chat for AI agents and chatbots.
Building a chat UI? createAgentHandler and <AgentChat>
mint and renew browser tokens for you. This page is for building your own.
1. Mint a token on your server
// POST /api/threads/:id/token, after checking the user may see this thread
const agent = await agents.upsert(`thread-${threadId}`, config);
return Response.json(await agents.runtime.browserToken(agent.id, { ttlSeconds: 900 }));
// { token, expiresAt, agentId, url }Python: await agents.runtime.browser_token(agent.id, ttl_seconds=900). REST:
POST /v1/agents/{id}/browser-tokens {ttlSeconds?, scopes?, events?, redact?, subject?}.
- The token reads that one agent: only
GET /v1/agents/{id}/events,/state,/historyand/inputs(or thescopesyou name). Everything else is a 403. Addchildrento its scopes and it reads the same of the agent's background sub-agents, by their ids. - It lasts
ttlSeconds(default 900, from 5 to 3,600), and its event stream ends when it expires. Nothing revokes it sooner, so keep it short. eventslimits the event types it gets. The watcher needsturn_opened,message_start,message_updateandmessage_endto follow messages.redact: ["usage.cost"]leaves every cost out of everything it reads: each message'susage.cost, and the provider's ownusage.providerCostandproviderCreditCost, in events, snapshots, history and inputs.- It never gets a run's result, only whether and why a run stopped.
- It works from any origin: the token grants the read. Send it as
Authorization: Bearer, never in a URL.
2. Watch from the browser
import { watchAgent } from "@camelai/run/watch";
const mint = () => fetch(`/api/threads/${id}/token`, { method: "POST" }).then(r => r.json());
const { token, expiresAt, agentId, url } = await mint();
const watcher = watchAgent({
url, agentId, token, expiresAt,
getToken: mint, // called before the token expires, and if it's refused
onChange: state => render(state),
onError: error => console.warn(error.message),
});
// Later: watcher.loadOlder() on scroll-up; watcher.close() when the view goes away.@camelai/run/watch has no dependencies and no Node APIs, and bundles
to under 30 KB. state is:
| Field | |
|---|---|
messages, indexes | The agent's messages, oldest first, each at its index in the history. A user message carries its requestId and your metadata, to match an optimistic bubble |
partial | The assistant message streaming now, with tool arguments parsed as they stream |
progress | The latest progress of each running tool call |
running | Whether a turn is running |
pendingInputs | Inputs the agent waits on. Answer them through your server |
lastOutcome | How the latest run ended: {id, stopped?, error?} |
hasOlder | Whether loadOlder() has more |
loaded | History and state have been read since it connected. Until then, no messages means they are on their way: show a loading state, not an empty chat |
connected, transport | sse, or poll where a proxy buffers streams |
expired | The token expired (or was refused) and couldn't be renewed, so the watcher stopped. Watch again with a new token |
- The watcher streams over SSE and falls back to long polls where streams
deliver nothing. It reconnects from its cursor, or from a snapshot of the
running turn, and closes the stream while the page is hidden
(
hiddenGraceMs, default 30 s). - Without
getToken, the watcher stops when its token expires, withstate.expiredset and an error saying so. PassgetTokenfor any view that stays open longer than the token. - In a browser, a network failure and a CORS refusal both look like "Failed to
fetch", so the watcher's error says which to check. The usual cause is a
token that isn't a browser token (cross-origin reads need one), or a wrong
url.
Send messages through your server
The browser only reads. Send messages, answers and aborts through your server, which checks the user and calls the agent:
// POST /api/threads/:id/messages
const agent = await agents.upsert(`thread-${threadId}`, config);
const run = await agent.run(text, { user: user.id, metadata: { clientId } });
return Response.json({ reply: run.text });The watcher shows the run as it happens, and metadata.clientId lets the page
match the stored message to the bubble it showed optimistically.
Ready-made components
camelRun serves a shadcn registry of React components:
a streaming chat with tool cards, questions, approvals and files
(agent-chat), its streaming-tolerant Markdown (agent-markdown), and the
Next.js App Router route the chat talks to (agent-route), which keeps your API
key on the server and lets each user reach only their own agent.
npx shadcn@latest add https://run.camelai.com/r/agent-chat.jsonhttps://run.camelai.com/r/registry.json lists them. See
Chat app.
Without the watcher
The same reads work from any code with a browser token or your API key:
GET /v1/agents/{id}/events (SSE, or ?poll=1&wait=25 for one JSON long poll),
/state, /history?limit=50 (pages of whole turns, newest first, before for
older) and /inputs. See Events.