Skip to content

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:

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

TypeScript
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 getsDescription
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
signalAborts 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:

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

DirectiontypeFieldsMeaning
page → workersendrunId, input (sessionId, prompt, attachments, model?, thinking?)Start a run
page → workerinterruptrunIdAbort it (the run's signal fires)
page → workersteerrunId, messagePass a message to the run's onSteer listener
page → workeranswerrequestId, response (null: dismissed)The user answered a request
worker → pagereadyThe worker is listening
worker → pageeventrunId, eventOne live StreamEvent
worker → pageaskrunId, requestThe run raised an InteractionRequest
worker → pagesteerablerunIdThe run registered onSteer
worker → pagedonerunId, 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.

TypeScript
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 })
OptionDescription
environmentRequired. What the agent is (below)
cwdRequired. Working directory of every session
authorize(request)Required. Runs on every request. false answers 401
basePathMount path. Default ''
modelsWhat GET /models lists. Default none: the composer hides its model picker
newSessionExtra fields for new sessions (providerKey, modelId, agentId)
replayLimitStream records kept per session for resumes. Default 2000
heartbeatMsKeep-alive interval. Default 15000

environment is one of:

EnvironmentAgent
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/ipcThis 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:

TypeScript
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.

RequestBodyResponse
GET /sessionsChatSession[], newest first
POST /sessions{ title? }201 ChatSession
PATCH /sessions/:id{ title?, archived?, favorite? }ChatSession
DELETE /sessions/:id204
GET /sessions/:id/events{ events, running, cursor, interactions }
GET /sessions/:id/streamLast-Event-ID headertext/event-stream
POST /sessions/:id/send{ prompt, attachments?, model?, thinking? }202 { ok: true }
POST /sessions/:id/interrupt204
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 /modelsChatModelProvider[] (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.

eventdataMeaning
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 (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.

TypeScript
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 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.

Terminal
zidane-server token create --name web --scope chat
ZIDANE_SERVER_ALLOWED_ORIGINS=https://app.example.com zidane-server start
FeatureHow
AuthenticateToken in the zidane.bearer.<token> WebSocket subprotocol (a browser cannot set headers)
VersionsThe 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 callThe hello lists the methods this connection may call (the server's, within the token's scope); client.can(method) reads it
Sessionssessions.list, create, setTitle, setArchived, delete; the sidebar's views per space in the server's state.json (sidebar.*)
Transcriptsessions.loadEvents, in pages of 200 turns. Earlier pages load through loadEarlier
Live updatesipc.signal notifications (event, run:start/run:end, interaction:*, sessions:changed), filtered by session
Runschat.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
Modelsmodels.list, for the composer's model picker
Tool callssessions.getToolResult (a cut output, in full), chat.cancelWork (stop one)
Questions and plansinteractions.list (per session, and every session's at once for interactions.all), respond, cancel
Transcript extrasThe 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 approvalscreateServerApprovalsHost({ 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)
TerminalscreateServerTerminalHost({ 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 wizardcreateServerOnboardingHost({ 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
ReconnectsBackoff, then each open transcript resumes from the last signal it saw (sessions.resume), or reloads when the server can't replay that far
RefusalsA 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.

LimitAdaptersDescription
Tool approvalscreateRunHost, createWorkerHost, createSseHostUnless 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 levelsAllSendInput.thinking carries 5 of zidane's 8 levels: off to high
Cross-origin refusalscreateServerHostA 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 exposurecreateServerHostThe 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 runtimecreateRunHost, createWorkerHostzidane's agent loop does not load in a browser, so run is scripted or calls an API that you proxy

Reference#

zidane-ui/adapters, zidane-ui/server, zidane/chat.