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), so the page code stays the same wherever the agent runs:
<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 StreamEvents 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.
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:
// 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.
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), 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:
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.
authorizeruns first on every request. Make sure that it checks a session cookie or a bearer token. Use() => trueonly on a server bound to127.0.0.1that nobody else can reach.- Every session runs in
cwd. Clients cannot pick a directory. Pointcwdat a directory that the agent is allowed to change, and give it only the tools it needs (demoToolsis one read-only lookup). - Every authorized user sees every session. To scope sessions per user, use one handler per tenant, or a
storekeyed by user. - Put HTTPS in front and disable proxy response buffering (the handler sets
X-Accel-Buffering: nofor nginx).
zidane-server, over WebSocket#
createServerHost speaks zidane-server's protocol (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.
import { createServerHost } from 'zidane-ui/adapters'
const host = createServerHost({ url: 'wss://agent.example.com', token: 'zds_…' })
await host.readyOther 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 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 compares it with the other clients.
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) |
| 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) |
| 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 |
| 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 have their own host (ApprovalsHost, approvals). 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 |