---
title: 'Show an agent in a browser'
sidebarTitle: 'Browser tokens'
description: 'Let a browser read one agent directly with a short-lived browser token'
---
[View as Markdown](https://camelai.com/docs/camelrun/browser.md) · [Docs index for coding agents](https://run.camelai.com/llms.txt)

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.

> **Tip:**
>
> Building a chat UI? [`createAgentHandler` and `<AgentChat>`](/docs/camelrun/chat-app)
> 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`, `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:

```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](/docs/camelrun/events).
