camelAI Documentation

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

ts
// 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, /history and /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.
  • events limits 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

ts
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, indexesThe 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
partialThe assistant message streaming now, with tool arguments parsed as they stream
progressThe latest progress of each running tool call
runningWhether a turn is running
pendingInputsInputs the agent waits on. Answer them through your server
lastOutcomeHow the latest run ended: {id, stopped?, error?}
hasOlderWhether loadOlder() has more
connected, transportsse, or poll where a proxy buffers streams
expiredThe 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:

ts
// 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.