Guides
Show an agent in a browser
Let a browser read one agent directly with a short-lived browser token
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.
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. Everything else is a 403. - 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.redact: ["usage.cost"]leaves provider costs out of everything it reads.- 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 |
connected, transport | sse, or poll where a proxy buffers streams |
expired | The token expired and couldn't be renewed, so the watcher stopped |
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.
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.
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.