# Connecting a host

> Back the chat components with a real agent, in the page, on your server or on zidane-server.

Each adapter returns a `ChatHost` ([pick a host](https://zidane.si/docs/ui-kit.md#pick-a-host)), so the page code stays the same wherever the agent runs:

```tsx
<ChatProvider host={host}>
  <SessionList activeId={id} onSelect={setId} onNewSession={create} />
  <HostTranscript sessionId={id} />
  <HostInteractions sessionId={id} />
  <HostComposer sessionId={id} />
</ChatProvider>
```

Every adapter carries zidane's `StreamEvent`s and merges them as the apps do. A `markdown` event with `streaming: true`, or a `thinking` event, extends the open block. Any other event is its own row. The reducer is `applyLiveEvent` from `zidane-ui/adapters`, built on `zidane/chat/pure`. The adapters keep their transcripts in the session stream store (`SessionStreamStore` from `zidane-ui/chat/headless`), the one the desktop app runs on. `createSessionStreams` wraps it with what a client of many concurrent sessions needs: per-session subscriptions, smooth streaming per (session, owner) on an injected frame scheduler, lazy scrollback (`loadEarlier`) and run-end compaction. Feed it a client's signals with `apply` and hand `transcript(sessionId)` to the components.

Runnable examples (scripted provider, no API key): `examples/ui-in-process`, `examples/ui-sse`, `examples/ui-server`.

In React, wrap once in `SessionStreamsProvider streams={streams}` (`zidane-ui/chat`) and read one session at a time: `useSessionEvents`, `useSessionRunning`, `useSessionPage`, `useSessionRetry`, `useSessionFallbackModel`, `useSessionSystemMd`, `useSessionEventsSelector` and `useSessionEdits` (the events that carry a file edit) subscribe to that session only, so a token on one session leaves the others' readers alone. `useSessionStreamActions` returns the store's writes as stable functions (`createSessionStreamActions`), and the `usePeek…` hooks read without subscribing, for event handlers.

## In the page or a Web Worker

`createRunHost` keeps sessions, transcripts and pending questions in memory and calls your `run` once per prompt. The host echoes the prompt, so `run` does not emit it.

```ts
import { createRunHost } from 'zidane-ui/adapters'

const host = createRunHost({
  async run({ prompt, emit, ask, signal }) {
    emit({ kind: 'markdown', text: 'Looking ', streaming: true })
    emit({ kind: 'markdown', text: 'it up.', streaming: true })
    emit({ kind: 'tool', tool: 'lookup', callId: 'c1', text: 'lookup(…)', input: { query: prompt } })
    emit({ kind: 'tool-result', tool: 'lookup', callId: 'c1', text: 'found it' })
    const answer = await ask(question) // an InteractionRequest; null when dismissed
    if (!signal.aborted)
      emit({ kind: 'markdown', text: answer ? 'Thanks.' : 'Never mind.', streaming: true })
  },
})
```

| `run` gets | Description |
| - | - |
| `sessionId`, `prompt`, `attachments`, `model?`, `thinking?` | The sent prompt |
| `emit(event)` | Stream one `StreamEvent` |
| `ask(request)` | Raise an `InteractionRequest`. It resolves with the answer, or `null` when dismissed or interrupted |
| `signal` | Aborts when the user presses Stop |
| `onSteer(listener)` | Receive the composer's steer messages. Without a listener, `steer` resolves `false` |

A follow-up waits for the current run to end, then runs as the next prompt. If `run` throws, the transcript gets an error row and the session ends with status `error`. Other options: `sessions`, `transcripts` (initial data), `models`, `newId`. The returned host also has `restore(session, events)`, which adds a saved session with its history at the top of the list.

To keep the work off the main thread, run the same function in a Web Worker:

```ts
// agent.worker.ts
import { exposeRunHost } from 'zidane-ui/adapters'

exposeRunHost(run)

// main.ts
import { createWorkerHost } from 'zidane-ui/adapters'

const host = createWorkerHost(new Worker(new URL('./agent.worker.ts', import.meta.url), { type: 'module' }))
```

zidane's agent runtime does not load in a browser (sessions use `AsyncLocalStorage`, execution contexts use the file system), and a provider key does not belong in a page. So `run` is scripted or calls an API that you proxy. For a real agent, use a server adapter below.

### Worker protocol

The messages are plain `postMessage` objects, each with `v: 1` and a `type`. A side that receives another version drops the message and logs a warning. Types: `ToWorker`, `FromWorker` from `zidane-ui/adapters`.

| Direction | `type` | Fields | Meaning |
| - | - | - | - |
| page → worker | `send` | `runId`, `input` (`sessionId`, `prompt`, `attachments`, `model?`, `thinking?`) | Start a run |
| page → worker | `interrupt` | `runId` | Abort it (the run's `signal` fires) |
| page → worker | `steer` | `runId`, `message` | Pass a message to the run's `onSteer` listener |
| page → worker | `answer` | `requestId`, `response` (`null`: dismissed) | The user answered a request |
| worker → page | `ready` | | The worker is listening |
| worker → page | `event` | `runId`, `event` | One live `StreamEvent` |
| worker → page | `ask` | `runId`, `request` | The run raised an `InteractionRequest` |
| worker → page | `steerable` | `runId` | The run registered `onSteer` |
| worker → page | `done` | `runId`, `error?` | The run returned, or threw (`error` holds the message) |

## Your server, over HTTP and SSE

`createSseHandler` is a Web-standard `(request: Request) => Promise<Response>` for `Bun.serve`, Deno, Hono or any fetch server. It runs `createChatHost` from `zidane/chat` (the engine of zidane-server and both apps) and streams its events to each watching client.

```ts
import { createLocalEnvironment, createSseHandler } from 'zidane-ui/server'
import { demoTools } from 'zidane-ui/server/testing'
import { anthropic } from 'zidane/providers'

const handler = createSseHandler({
  environment: createLocalEnvironment({ provider: anthropic(), tools: demoTools }),
  cwd: '/srv/agent-workspace',
  basePath: '/api/chat',
  authorize: request => isSignedIn(request),
})

Bun.serve({ port: 4380, idleTimeout: 0, fetch: handler })
```

| Option | Description |
| - | - |
| `environment` | Required. What the agent is (below) |
| `cwd` | Required. Working directory of every session |
| `authorize(request)` | Required. Runs on every request. `false` answers 401 |
| `basePath` | Mount path. Default `''` |
| `models` | What `GET /models` lists. Default none: the composer hides its model picker |
| `newSession` | Extra fields for new sessions (`providerKey`, `modelId`, `agentId`) |
| `replayLimit` | Stream records kept per session for resumes. Default `2000` |
| `heartbeatMs` | Keep-alive interval. Default `15000` |

`environment` is one of:

| Environment | Agent |
| - | - |
| `createLocalEnvironment({ provider, tools?, model?, system?, store?, settings?, dataDir? })` | One provider and the tools you list (default none). Reads no zidane config: no MCP servers, skills or settings. Sessions live in `store`, in memory by default. Pass any zidane `SessionStore` to keep them. Without `dataDir` it makes a temp directory on first use, which the handler's `close()` (or the factory's `dispose()`) removes |
| `broadcast => createDaemonEnvironment({ broadcast, cwd })` from `zidane/ipc` | This machine's zidane setup: providers signed in with `zidane auth`, every built-in tool, MCP servers, skills. The kit cannot answer tool approvals ([limits](#limits)), so use it only with Tool approvals set to Full access |

`createScriptedProvider()` from `zidane-ui/server/testing` is an offline, deterministic provider for demos and tests. It calls `lookup` once, then answers with the result.

In the browser:

```ts
import { createSseHost } from 'zidane-ui/adapters'

const host = createSseHost({ baseUrl: '/api/chat', headers: () => ({ authorization: `Bearer ${token()}` }) })
```

The client streams with `fetch`, so `headers` reach the stream request too. A transcript hydrates when first watched, then streams. After a drop it reconnects with `Last-Event-ID`, from 500 ms (`reconnectMs`) doubling to 10 s. A stream closes one second after nothing watches it.

The handler sets no CORS headers. Serve the page from the same origin, or proxy the requests as the example does.

### SSE protocol

This is version 1. Every response carries `X-Zidane-Chat-Protocol: 1`. Paths are relative to `basePath`, and bodies are JSON. Types: `HydrateResponse`, `SendBody`, `StreamRecord`, `AnswerBody` from `zidane-ui/adapters`.

| Request | Body | Response |
| - | - | - |
| `GET /sessions` | | `ChatSession[]`, newest first |
| `POST /sessions` | `{ title? }` | `201 ChatSession` |
| `PATCH /sessions/:id` | `{ title?, archived?, favorite? }` | `ChatSession` |
| `DELETE /sessions/:id` | | `204` |
| `GET /sessions/:id/events` | | `{ events, running, cursor, interactions }` |
| `GET /sessions/:id/stream` | `Last-Event-ID` header | `text/event-stream` |
| `POST /sessions/:id/send` | `{ prompt, attachments?, model?, thinking? }` | `202 { ok: true }` |
| `POST /sessions/:id/interrupt` | | `204` |
| `POST /sessions/:id/steer` | `{ message }` | `{ ok }` |
| `POST /sessions/:id/follow-up` | `{ message }` | `{ ok }` |
| `POST /interactions/:requestId/answer` | `{ response }` | `{ ok }` |
| `POST /interactions/:requestId/cancel` | `{ reason? }` | `{ ok }` |
| `GET /models` | | `ChatModelProvider[]` (the `models` option) |

Each stream record has `id: <epoch>:<seq>`, `event: <type>` and one `data:` line of JSON. `seq` counts one session's records from 1; `epoch` identifies the server process.

| `event` | `data` | Meaning |
| - | - | - |
| `event` | `{ event, echo? }` | A live `StreamEvent`. `echo` marks a repeated prompt the sending client already shows |
| `run` | `{ running, ok?, error? }` | A run started or ended |
| `interaction` | `{ request }` | The agent asks a question or presents a plan |
| `interaction-resolved` | `{ requestId }` | Answered or withdrawn |
| `session` | `{ session }` | The session row changed (status, title) |
| `reset` | `{}` | The records after `Last-Event-ID` are gone (restart, or the replay buffer moved on). Hydrate again |

The handler keeps each watched session's transcript and its last `replayLimit` records in memory, and rebuilds history from the store with `eventsFromTurns`. It uploads attachments with a base64 `data:` URL and drops others. `model` (`provider/model`) applies to the session before sending.

### Security

The SSE handler runs the agent and its tools on your server, as the server's user.

- `authorize` runs first on every request. Make sure that it checks a session cookie or a bearer token. Use `() => true` only on a server bound to `127.0.0.1` that nobody else can reach.
- Every session runs in `cwd`. Clients cannot pick a directory. Point `cwd` at a directory that the agent is allowed to change, and give it only the tools it needs (`demoTools` is one read-only lookup).
- Every authorized user sees every session. To scope sessions per user, use one handler per tenant, or a `store` keyed by user.
- Put HTTPS in front and disable proxy response buffering (the handler sets `X-Accel-Buffering: no` for nginx).

## zidane-server, over WebSocket

`createServerHost` speaks zidane-server's [protocol](https://zidane.si/docs/server-protocol.md) (JSON-RPC 2.0 over one WebSocket) through zidane's browser client, `createServerClient` (`zidane/ipc/browser`, re-exported here). It runs wherever a `WebSocket` does: a page, a worker, Node, Bun.

```ts
import { createServerHost } from 'zidane-ui/adapters'

const host = createServerHost({ url: 'wss://agent.example.com', token: 'zds_…' })
await host.ready
```

Other options: `cwd` (default: the server's first workspace root), `appVersion` (default `'web'`, shown in the server logs), `WebSocket`, `fetch`, `reconnect` (`{ initialMs, maxMs }`, default 500 doubling to 15 s, or `false`), `heartbeatMs` (default 20 s), `connectTimeoutMs` (default 10 s), `onError`. Or pass `client`, a `createServerClient(…)` to share with other hosts on the same server. The returned host also has `client`, `connection` (a readable store of the connection's state), `refresh()` (reads the session list again) and `close()`.

Mint a token with the narrowest [scope](https://zidane.si/docs/server-security.md#scopes) the page needs (`chat` to chat and answer tool approvals, `admin` for everything) and add the page's origin to the allowlist, which starts empty (an unlisted `Origin` gets a 403). [Server clients](https://zidane.si/docs/server-clients.md#browser) compares it with the other clients.

```sh
zidane-server token create --name web --scope chat
ZIDANE_SERVER_ALLOWED_ORIGINS=https://app.example.com zidane-server start
```

| Feature | How |
| - | - |
| Authenticate | Token in the `zidane.bearer.<token>` WebSocket subprotocol (a browser cannot set headers) |
| Versions | The client sends the range of protocol versions it speaks; the server answers in the highest one both share, so a page keeps working when the server moves one version on |
| What it may call | The hello lists the methods this connection may call (the server's, within the token's scope); `client.can(method)` reads it |
| Sessions | `sessions.list`, `create`, `setTitle`, `setArchived`, `delete`; the sidebar's views per space in the server's `state.json` (`sidebar.*`) |
| Transcript | `sessions.loadEvents`, in pages of 200 turns. Earlier pages load through `loadEarlier` |
| Live updates | `ipc.signal` notifications (`event`, `run:start`/`run:end`, `interaction:*`, `sessions:changed`), filtered by session |
| Runs | `chat.send` (base64 attachments), after `sessions.setModel` / `setEffort` when the composer picks a model or a thinking level; `chat.interrupt`; a send while a run works goes through `queue.add` with its `delivery` |
| Models | `models.list`, for the composer's model picker |
| Tool calls | `sessions.getToolResult` (a cut output, in full), `chat.cancelWork` (stop one) |
| Questions and plans | `interactions.list` (per session, and every session's at once for `interactions.all`), `respond`, `cancel` |
| Transcript extras | The extensions' references (`references.providers` / `resolve`) and badge items (`badges.actions` / `run`). No site icons: the web app's Content-Security-Policy keeps a page from loading other sites' images |
| Tool approvals | `createServerApprovalsHost({ client })`, on the same client: `approvals.listPending` (again after each reconnect), `resolve`, `denyAll`, the `approval:request` / `approval:resolved` signals. Answering needs a `chat` token. Built after the handshake, the host follows `client.can('approvals.resolve')`: a connection that may not call it shows approvals without buttons (`canApprove` overrides it) ([approvals](https://zidane.si/docs/ui-approvals.md)) |
| Terminals | `createServerTerminalHost({ client })`, on the same client: `terminal.state`, `open`, `attach`, `write`, `resize`, `ack`, `close`, `restart`, `setHidden`, project and draft terminals when the hello says `projectTerminals`, the `terminal:*` signals; views re-attach after each reconnect. Every method needs an `admin` token ([terminal](https://zidane.si/docs/ui-terminal.md)) |
| Setup wizard | `createServerOnboardingHost({ client })`, on the same client: `settings.get` / `set`, `onboarding.detect` / `providers`, `auth.status`, `setApiKey`, `configure`, `auth.oauth.*`, `models.list` / `test`, `catalog.mcps` / `skills` / `extensions` / `install`, `skills.browse`, `claudeImport.*`, `mcp.upsert`. See [onboarding](https://zidane.si/docs/ui-onboarding.md) |
| Reconnects | Backoff, then each open transcript resumes from the last signal it saw (`sessions.resume`), or reloads when the server can't replay that far |
| Refusals | A bad or revoked token, a refused origin or a protocol the two don't share ends the client (`connection` says which) instead of retrying. A browser can't read why an upgrade was refused, so the client asks the server's auth check (`/zidane/auth`) |

## Limits

[Tool approvals](https://zidane.si/docs/approvals.md) have their own host (`ApprovalsHost`, [approvals](https://zidane.si/docs/ui-approvals.md)). Only zidane-server has one: `createServerApprovalsHost`.

| Limit | Adapters | Description |
| - | - | - |
| Tool approvals | `createRunHost`, `createWorkerHost`, `createSseHost` | Unless Tool approvals is Full access, a tool that needs approval waits for an answer that these adapters cannot give. With `createDaemonEnvironment`, run with Full access. On zidane-server, `createServerApprovalsHost` answers them with a `chat` token |
| Reasoning levels | All | `SendInput.thinking` carries 5 of zidane's 8 levels: `off` to `high` |
| Cross-origin refusals | `createServerHost` | A page on an origin the server doesn't allow can't read the refusal at all (no CORS headers for it): the client reports a failed connect and keeps retrying. List the origin, or serve the page from the server |
| Token exposure | `createServerHost` | The token sits in the page, readable by anyone who opens it. Use a dedicated token with the narrowest scope. Revoke it with `zidane-server token revoke` |
| No agent runtime | `createRunHost`, `createWorkerHost` | zidane's agent loop does not load in a browser, so `run` is scripted or calls an API that you proxy |

## Reference

[`zidane-ui/adapters`](https://zidane.si/docs/reference/ui-adapters.md), [`zidane-ui/server`](https://zidane.si/docs/reference/ui-server.md), [`zidane/chat`](https://zidane.si/docs/reference/chat.md).
