# Server protocol

> The zidane-server wire protocol, with the WebSocket upgrade, the ipc.hello handshake, every JSON-RPC method and ipc.signal kind, errors, resume and paging, and captured examples.

Protocol version `16` is JSON-RPC 2.0 over one WebSocket, with one message per text frame. The client sends `ipc.hello` first, then requests. The server answers them and pushes `ipc.signal` notifications.

The tables come from `src/ipc/protocol.ts` and `src/chat/host/signals.ts`. The JSON examples come from a real server run with a scripted provider. The [local daemon](https://zidane.si/docs/daemon.md) speaks the same protocol over a unix socket. [`zidane/ipc`](https://zidane.si/docs/reference/ipc.md) and [`zidane/ipc/remote`](https://zidane.si/docs/reference/ipc-remote.md) type it for TypeScript clients.

## Connect

| Step | Client | Server |
| - | - | - |
| 1. Upgrade | `GET` with `Upgrade: websocket`, the token, `Sec-WebSocket-Protocol: zidane` | Checks the address rate, the origin, the proxy headers, the token and the connection caps. Selects `zidane` |
| 2. Handshake | `ipc.hello` with its protocol version | Accepts or answers `VersionMismatch` |
| 3. Work | Requests, and notifications (no `id`) for fire-and-forget calls | Responses, and `ipc.signal` notifications |
| 4. Liveness | `$/ping` requests | WebSocket pings every 20 s |

The token travels in a header or a subprotocol:

| Carrier | Header | Used by |
| - | - | - |
| Header (preferred) | `Authorization: Bearer zds_…` | Clients that can set handshake headers (Bun's WebSocket: the TUI, the CLI) |
| Subprotocol | `Sec-WebSocket-Protocol: zidane, zidane.bearer.zds_…` | Node's and the browser's WebSocket |

A proxy can log `Sec-WebSocket-Protocol`. Use the header where the runtime can set it.

```ts
const ws = new WebSocket('wss://zidane.example.com', ['zidane', `zidane.bearer.${token}`])
```

### HTTP on the same port

The port also answers plain HTTP. `GET /healthz` needs no token. A refused upgrade gets its status before any JSON-RPC. Captured answers:

```json
[
  {
    "title": "health",
    "request": {
      "method": "GET",
      "path": "/healthz",
      "headers": []
    },
    "status": 200,
    "body": "ok"
  },
  {
    "title": "no token",
    "request": {
      "method": "GET",
      "path": "/",
      "headers": [
        "Upgrade",
        "Connection",
        "Sec-WebSocket-Key",
        "Sec-WebSocket-Version"
      ]
    },
    "status": 401,
    "body": "Unauthorized"
  },
  {
    "title": "unknown token",
    "request": {
      "method": "GET",
      "path": "/",
      "headers": [
        "Upgrade",
        "Connection",
        "Sec-WebSocket-Key",
        "Sec-WebSocket-Version",
        "Sec-WebSocket-Protocol"
      ]
    },
    "status": 401,
    "body": "Unauthorized"
  },
  {
    "title": "browser origin",
    "request": {
      "method": "GET",
      "path": "/",
      "headers": [
        "Upgrade",
        "Connection",
        "Sec-WebSocket-Key",
        "Sec-WebSocket-Version",
        "Origin",
        "Sec-WebSocket-Protocol"
      ]
    },
    "status": 403,
    "refusal": "origin",
    "body": "Forbidden: origin not allowed"
  }
]
```

[Every refusal](https://zidane.si/docs/server-security.md#refused-upgrades).

`POST /zidane/pair` trades a pairing code for a token ([pairing](https://zidane.si/docs/server-security.md#pairing)). `GET /zidane/media` serves a workspace's images, videos and audio to a page that plays them by address, under the ticket its connection registered (`files.mediaTicket`, below). `GET /zidane/oauth/callback` takes a browser client's MCP sign-in back (`auth.oauth.begin`, below). With the [web app](https://zidane.si/docs/server-install.md#web-app) on, every other plain `GET` serves the app; upgrades on any path still reach the WebSocket, so the app connects to `wss://<host>/`.

## Handshake

```json title="request"
{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "ipc.hello",
  "params": {
    "protocolVersion": 13,
    "appVersion": "docs",
    "pid": 4242
  }
}
```

```json title="response"
{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "protocolVersion": 13,
    "appVersion": "docs",
    "pid": 28973,
    "startedAt": 1791968973803,
    "buildId": "1791968503753",
    "codeVersion": "8.0.4",
    "hostId": "08b260f9-090c-445b-957a-1b6f4822f245",
    "hostKind": "server",
    "hostName": "docs-box",
    "signalEpoch": "ed173759-2d4f-436a-b39a-36c1c969e088",
    "remoteInstalls": true,
    "sync": true
  }
}
```

### ipc.hello params

| Field | Type | Description |
| - | - | - |
| `protocolVersion` | `number` | Protocol version the client speaks. |
| `minProtocolVersion?` | `number` | The oldest version the client can speak down to (`IPC_MIN_PROTOCOL_VERSION`). A zidane-server accepts when the ranges overlap. Absent: `protocolVersion` only. Additive. |
| `appVersion` | `string` | Client's zidane version; surfaced in daemon diagnostics. |
| `pid` | `number` | Process id of the client. |
| `buildId?` | `string` | Build fingerprint (module mtime; `'unknown'` in compiled binaries). When BOTH sides report a real id and they differ, the daemon rejects the handshake like a protocol-version mismatch; the client drains it and respawns. Closes same-version code drift during development. |
| `codeVersion?` | `string` | Version of the zidane code the client runs (`ZIDANE_VERSION`, inlined at build). Unlike `buildId` this survives compiled binaries, so it closes the gap released builds had: same protocol version, older code. A client STRICTLY NEWER than the daemon is rejected like a build mismatch and the stale daemon is drained; an older client is accepted and degrades via `optional()`. |

### ipc.hello result

| Field | Type | Description |
| - | - | - |
| `protocolVersion` | `number` | Protocol version the host speaks. |
| `minProtocolVersion?` | `number` | The oldest version the host still speaks (`IPC_MIN_PROTOCOL_VERSION`). Additive. |
| `negotiatedProtocolVersion?` | `number` | The version this connection speaks: the lower of the client's and the host's. Additive: absent, the connection speaks `protocolVersion`. |
| `scope?` | `ClientScope` | The client scope this connection's token grants (`./scopes`): calls above it are refused. Absent: every method (a local window). Additive. |
| `methods?` | `IpcMethodName[]` | The methods this connection may call on this host: the ones it handles, within `scope`. Clients show what is offered instead of failing on what isn't. Additive. |
| `appVersion` | `string` | zidane version of the host. |
| `pid` | `number` | Process id of the host. |
| `startedAt` | `number` | Start time of the host process, epoch ms. |
| `buildId?` | `string` | Build fingerprint of the host code. `'unknown'` in compiled binaries. |
| `codeVersion?` | `string` | Version of the zidane code the host runs. |
| `hostId?` | `string` | Host identity (additive). `hostId` is a UUID persisted once in the host's data dir, so it survives restarts; the in-process host and the local daemon share the data dir, and so the id: they are the same computer. |
| `hostKind?` | `HostKind` | What kind of host this is. |
| `hostName?` | `string` | Display name of the host. |
| `retiring?` | `boolean` | The daemon is older than a client that attached while it had active work: it accepted the handshake instead of draining (which would abort those runs) and exits once idle. Clients attach normally; their reconnect path lands on a fresh daemon after the exit. |
| `signalEpoch?` | `string` | Names this host process's signal numbering (`SIGNAL_SEQ_FIELD`): a position from another epoch cannot be resumed. Additive. |
| `machineServe?` | `boolean` | This server lends itself as a machine to the hosts of its clients (`machine.attach`, `allowAsMachine` on). Absent: it doesn't. Additive. |
| `adminCommands?` | `{ token: string, pair: string }` | How to get an admin token on this zidane-server: the commands to run on its machine, with its own binary, user and data dir (`<device>` is the name to fill in). `token` prints a token to paste into an app; `pair` prints a code for a browser. Absent: not a zidane-server. Additive. |
| `remoteInstalls?` | `boolean` | This server takes installs and removals from its clients (`catalog.install` / `remove` / `update`, `mcp.upsert` / `remove`; `allowRemoteInstalls` on). Absent: it doesn't (or predates them). Additive. |
| `clone?` | `boolean` | Clients may clone repositories into its workspace roots (`git.clone`). A zidane-server's `allowClone`; a local host always does. Absent: an older host, where `git.clone` needs an admin token. Additive. |
| `createFolders?` | `boolean` | Clients may create folders in its workspace roots (`fs.mkdir`). A zidane-server's `allowCreateFolders`; a local host always does. Absent: an older host, without `fs.mkdir`. Additive. |
| `homeDir?` | `string` | The host process's home folder (`os.homedir()`). Clients show it as `~` in the paths they display; nothing else reads it. Absent: an older host. Additive. |
| `sync?` | `boolean` | This server takes settings sync from a client's computer (`sync.*`). Absent: it doesn't. Additive. |
| `syncCategories?` | `SyncCategory[]` | The sync categories it takes. Absent with `sync`: the ones before `environments`. Additive. |
| `projectTerminals?` | `true` | This host runs terminals without a session (`terminal.openInProject` and the other project / draft terminal methods). Absent: sessions only. Additive. |
| `activeWork?` | `boolean` | `daemon.info` only: a run, compaction, `/` command or background task is in flight on this host (open terminals do not count). `zidane server wait-idle` waits for `false` before a restart. Additive. |

`remoteInstalls`, `sync`, `machineServe` and `projectTerminals` say what this server offers. An absent field means no. `clone` and `createFolders` say whether clients may clone into the workspace roots and create folders there (`allowClone`, `allowCreateFolders`); an older server leaves them out. `methods` lists what this connection may call: the server's methods within the token's `scope` ([Scopes](https://zidane.si/docs/server-security.md#scopes)). A client shows what it lists instead of failing on the rest. `hostId` stays the same across restarts. A client that saved the server checks it on every connect and refuses another server at the same URL. `signalEpoch` names this server process for [resume](#reconnect-and-resume). `adminCommands` (`{ token, pair }`) are the commands that mint an admin token or an admin pairing code, run on the server's machine with its binary, its user and its data dir. The apps show them to a `chat` token.

### Compatibility

Each side speaks a range of versions, from its `minProtocolVersion` to its `protocolVersion`. A server accepts a client whose range overlaps its own, and the connection speaks the higher version both share (`negotiatedProtocolVersion` in the result). A client that sends no `minProtocolVersion` speaks its `protocolVersion` only. A release keeps the previous version in its range when it raises the current one, so apps built against one release keep working against the next. Ranges that don't overlap get `VersionMismatch`, with the server's hello fields in `data.daemon` and the side to upgrade in the message:

```json
{
  "jsonrpc": "2.0",
  "id": 2,
  "error": {
    "code": -32010,
    "message": "Protocol version mismatch: the server speaks v13, the client v12. Upgrade the client.",
    "data": {
      "daemon": {
        "protocolVersion": 13,
        "appVersion": "docs",
        "pid": 28973,
        "startedAt": 1791968973803,
        "buildId": "1791968503753",
        "codeVersion": "8.0.4",
        "hostId": "08b260f9-090c-445b-957a-1b6f4822f245",
        "hostKind": "server",
        "hostName": "docs-box"
      }
    }
  }
}
```

Within a shared version, the server ignores `buildId` and `codeVersion`, so older and newer clients attach. Only breaking changes to the method map or the signal union bump the version.

New methods and fields arrive without a bump. A client treats `MethodNotFound` (-32601) as "not offered". `daemon.shutdown` does nothing on a server. No client can drain or restart one.

Before the handshake, the server answers only `ipc.hello`, `$/ping`, `daemon.info` and `daemon.shutdown`:

```json
{
  "jsonrpc": "2.0",
  "id": 2,
  "error": {
    "code": -32011,
    "message": "Handshake required: send ipc.hello first."
  }
}
```

## Requests and notifications

Every method takes an object as `params` and answers with a result or an error. A call without an `id` runs as a notification. The server sends no answer and drops errors. Drafts use notifications for every keystroke.

```json title="request"
{
  "jsonrpc": "2.0",
  "id": 5,
  "method": "sessions.create",
  "params": {
    "cwd": ""
  }
}
```

```json title="response"
{
  "jsonrpc": "2.0",
  "id": 5,
  "result": {
    "id": "7f6b2bc1-27b2-4052-a2b7-de05d2538178",
    "title": "New session",
    "projectRoot": "/data/workspaces",
    "cwd": "/data/workspaces",
    "agentId": "build",
    "status": "idle",
    "createdAt": 1791968973862,
    "updatedAt": 1791968973862,
    "archived": false,
    "hostId": "08b260f9-090c-445b-957a-1b6f4822f245"
  }
}
```

`cwd: ''` asks for the server's first workspace root. A folder outside the workspace roots is refused.

```json title="request"
{
  "jsonrpc": "2.0",
  "id": 7,
  "method": "chat.send",
  "params": {
    "sessionId": "7f6b2bc1-27b2-4052-a2b7-de05d2538178",
    "prompt": "Say hello."
  }
}
```

```json title="response"
{
  "jsonrpc": "2.0",
  "id": 7,
  "result": {
    "runId": "6f577015-ffbf-4204-ba81-320f71e76f46"
  }
}
```

`chat.send` answers once the run starts. The run itself arrives as signals.

## Signals

The server pushes host events as `ipc.signal` notifications whose params are the signal, `kind` first. A session signal also carries `$seq`, its number in that session (from 1, without gaps, within one server process). Signals without a session, such as `presence:changed`, have none.

The run above, in order: `run:start`, `plans:snapshot`, `prompt-turns`, one `event` per streamed piece, `stream:progress`, `turn:end`, `run:end`.

```json
{
  "jsonrpc": "2.0",
  "method": "ipc.signal",
  "params": {
    "kind": "run:start",
    "sessionId": "7f6b2bc1-27b2-4052-a2b7-de05d2538178",
    "runId": "6f577015-ffbf-4204-ba81-320f71e76f46",
    "$seq": 4
  }
}
```

```json
{
  "jsonrpc": "2.0",
  "method": "ipc.signal",
  "params": {
    "kind": "event",
    "sessionId": "7f6b2bc1-27b2-4052-a2b7-de05d2538178",
    "event": {
      "kind": "markdown",
      "text": "H",
      "streaming": true,
      "turnId": "999068c5-c52b-4ccd-a936-682bea162657"
    },
    "$seq": 7
  }
}
```

```json
{
  "jsonrpc": "2.0",
  "method": "ipc.signal",
  "params": {
    "kind": "turn:end",
    "sessionId": "7f6b2bc1-27b2-4052-a2b7-de05d2538178",
    "turnId": "999068c5-c52b-4ccd-a936-682bea162657",
    "usage": {
      "input": 10,
      "output": 33,
      "modelId": "mock",
      "timeToFirstTokenMs": 2,
      "streamDurationMs": 2
    },
    "$seq": 42
  }
}
```

```json
{
  "jsonrpc": "2.0",
  "method": "ipc.signal",
  "params": {
    "kind": "run:end",
    "sessionId": "7f6b2bc1-27b2-4052-a2b7-de05d2538178",
    "runId": "6f577015-ffbf-4204-ba81-320f71e76f46",
    "ok": true,
    "$seq": 45
  }
}
```

```json
{
  "jsonrpc": "2.0",
  "method": "ipc.signal",
  "params": {
    "kind": "presence:changed",
    "sessions": [
      {
        "sessionId": "7f6b2bc1-27b2-4052-a2b7-de05d2538178",
        "windows": 1,
        "running": true,
        "attention": false
      }
    ]
  }
}
```

Most signals reach every connection. Streamed command output (`workspace:exec:data`, `workspace:exec:exit`) goes only to the connection that started the command. A client ignores unknown kinds, because new kinds arrive without a version bump.

| Kind | Params | Description |
| - | - | - |
| `run:start` | `{ kind: 'run:start', sessionId: string, runId: string, startedAt?: number }` | A run started in the session. |
| `run:end` | `{ kind: 'run:end', sessionId: string, runId: string, ok: boolean, error?: string, detail?: RunErrorDetail, background?: true, takenOver?: true }` | `takenOver`: another process claimed the run, which goes on there (`error` says so). Optional: older clients show `error`. |
| `turn:end` | `{ kind: 'turn:end', sessionId: string, turnId: string, childId?: string, depth?: number, usage?: TurnUsage, runOutputTokens?: number }` | A turn finished. `usage` carries the loop-reported token/cost figures that drive footer gauges (context size = prompt-side tokens of the LAST parent turn; cost accrues across parent + child turns). A parent turn's `runOutputTokens` is its run's output so far, this turn included: a run readout takes it as is instead of adding turns up, so a window that read the count mid-run never counts a turn twice. |
| `event` | `{ kind: 'event', sessionId: string, event: StreamEvent, echo?: boolean }` | `echo: true` marks host-generated SEND/STEER echoes of the user's own message. Clients that already render the prompt locally (the GUI's enriched append) drop echo-tagged signals; command-module user-prompts are never echoes and always render. |
| `compacting:start` | `{ kind: 'compacting:start', sessionId: string }` | Compaction of the session's history started. |
| `compacting:progress` | `{ kind: 'compacting:progress', sessionId: string, estOutputTokens: number }` | Live token estimate for an in-flight compaction summary. Throttled by the loop, so this is a low-rate progress tick rather than a stream. Without it a daemon-mode window shows a static "Compacting…" with no indication of progress; a compaction of a large transcript can run for minutes, which is indistinguishable from a hang. |
| `stream:progress` | `({ kind: 'stream:progress', sessionId: string } & StreamProgressInfo)` | Live counters for the parent turn currently streaming; the loop's `stream:progress` (throttled \~4/s, one final tick before `stream:end`). `outputTokens` is provider-counted unless `estimated`; `estCostUsd` is the running catalog-rate estimate for THIS turn (absent for unpriced models and flat-rate subscription traffic). Clients add it to the settled session figure while streaming and drop it when the matching `turn:end` brings the real `usage.cost`. |
| `prompt-turns` | `{ kind: 'prompt-turns', sessionId: string, refs: readonly PromptTurnRef[] }` | Prompt turns that just persisted, reduced to the fields needed to bind a live `user-prompt` event to its turn id (see `PromptTurnRef`). Deliberately NOT the full `SessionTurn`: those carry every content block, including base64 image/document payloads, which would be megabytes per prompt to ship for the sake of an id. Without this a prompt sent from a daemon-attached window is unselectable; no hover, edit, or delete; until the run-end transcript replace. |
| `compacting:end` | `{ kind: 'compacting:end', sessionId: string, ok: boolean, effectiveTokens?: number }` | `effectiveTokens` (success only) is the loop's post-compact context estimate; windows drop their footer ctx gauge to it immediately instead of waiting for the next turn's usage. |
| `model:fallback` | `{ kind: 'model:fallback', sessionId: string, from: { provider: string, model: string }, to: { provider: string, model: string }, discardTurnId?: string }` | The run moved from one model to the next in the session's fallback chain. |
| `work:snapshot` | `{ kind: 'work:snapshot', sessionId: string, items: RunningWorkItem[] }` | The session's running work, as `chat.listWork` returns it, in full. |
| `plans:snapshot` | `{ kind: 'plans:snapshot', sessionId: string, snapshot: PlansSnapshot }` | The session's plans, in full, and the one worth a glance. |
| `artifact:recorded` | `{ kind: 'artifact:recorded', sessionId: string, artifact: SessionArtifact }` | The session recorded an artifact (PR, branch, commit, file, extension kind; see `session/artifacts.ts`). Carries the stored row so a window can badge / list without a round-trip; `sessions.artifacts` is the authoritative query. Additive; older clients ignore unknown kinds. |
| `artifact:changed` | `{ kind: 'artifact:changed', sessionId: string, op: 'delete' \| 'upsert', artifactId: string, artifact?: SessionArtifact }` | An artifact was removed or refreshed through the host (`artifacts.delete` / `artifacts.upsert`); windows re-query. Carries the row for `upsert`. |
| `interaction:request` | `{ kind: 'interaction:request', sessionId: string, request: InteractionRequest, live: boolean }` | The agent asked a question or proposed a plan and waits for an answer. |
| `interaction:resolved` | `{ kind: 'interaction:resolved', sessionId: string, requestId: string, answeredBy?: InteractionAnswerer }` | A pending question or plan was answered or withdrawn. |
| `secrets:changed` | `{ kind: 'secrets:changed', projectKey: string }` | A project's secret store changed (a secret saved, revoked or expired in this process). Carries no names or values: a client lists again. Not numbered: no session. |
| `browser:request` | `{ kind: 'browser:request', sessionId: string, hostId: string, request: BrowserRequest }` | Dev browser (`browser.ts` hub): a tool call for the page, addressed to the registered browser host (`hostId`); other clients ignore it. The host answers through `browser.respond`. `browser:cancel` withdraws it. |
| `browser:cancel` | `{ kind: 'browser:cancel', sessionId: string, hostId: string, requestId: string }` | Withdraws a `browser:request`. |
| `terminal:changed` | `{ kind: 'terminal:changed', sessionId: string, terminals: TerminalInfo[] }` | Terminals (`terminal/` manager). `changed` carries the session's full list; `output` is a batch starting at `offset` (clients drop what they already have and `terminal.ack` what they wrote); `reset` replaces a view's content with history after a client fell too far behind. |
| `terminal:output` | `{ kind: 'terminal:output', sessionId: string, terminalId: string, offset: number, data: string }` | Output of a session terminal, from `offset`. |
| `terminal:reset` | `{ kind: 'terminal:reset', sessionId: string, terminalId: string, offset: number, data: string }` | A terminal's whole view with its history, for a client that fell too far behind. |
| `terminal:project-changed` | `{ kind: 'terminal:project-changed', root: string, draftId?: string, terminals: TerminalInfo[] }` | The full list of a project's terminals (`draftId` absent) or of one draft's. No `sessionId`: these belong to no session (session signals are numbered and replayed per session). Output of these terminals still arrives as `terminal:output` / `terminal:reset` with `sessionId: ''`, sent only to the views attached to them. Not numbered: no session. |
| `workspace:exec:data` | `{ kind: 'workspace:exec:data', execId: string, stream: 'stdout' \| 'stderr', chunk: string }` | A streamed workspace command (`sessions.workspaceExecStream`): its output in arrival order, then exactly one `exit`. Sent to the connection that started it only, never fanned out or replayed. `code` is `null` and `signal` set when the host killed it (`cancelled`: the client asked; `truncated`: the output cap was reached). Not numbered: no session. |
| `workspace:exec:exit` | `{ kind: 'workspace:exec:exit', execId: string, code: number \| null, signal: string \| null, truncated?: true, cancelled?: true }` | A streamed workspace command ended: its exit code or signal; `truncated` when the output cap was reached. Not numbered: no session. |
| `block-run:data` | `{ kind: 'block-run:data', runId: string, stream: 'stdout' \| 'stderr', chunk: string }` | A chunk of a Run here's output (`sessions.runBlock`), to the connection that started it only. Not numbered: no session. |
| `block-run:end` | `{ kind: 'block-run:end', runId: string, run?: BlockRun, error?: string }` | A Run here ended: the saved run, or why it could not run. To the connection that started it only. Not numbered: no session. |
| `git:clone:progress` | `{ kind: 'git:clone:progress', phase: string, percent: number }` | Git's progress on the connection's `git.clone`, to that connection only. Not numbered: no session. |
| `git:refs` | `{ kind: 'git:refs', cwd: string }` | A checkout's refs moved (`git.watch`), to the connections watching `cwd` only. Not numbered: no session. |
| `panel:changed` | `{ kind: 'panel:changed', cwd: string, panelId: string }` | An extension panel's content changed (`panels.watch`), to the connections watching it only. Not numbered: no session. |
| `palette:changed` | `{ kind: 'palette:changed', cwd: string }` | `cwd`'s extension palette items may have changed (`palette.watch`), to the connections watching only. Not numbered: no session. |
| `logs:record` | `{ kind: 'logs:record', record: LogRecord }` | A new record in a zidane-server's log (`logs.watch`), to the connections following it only. Not numbered: no session. |
| `perf:sample` | `{ kind: 'perf:sample', sample: PerfSample }` | The host's performance sample (`perf.watch`), to the connections watching it only. Not numbered: no session. |
| `catalog:changed` | `{ kind: 'catalog:changed', catalog: 'skills' \| 'mcps' \| 'extensions' \| 'plugins' \| 'models' }` | A client installed, updated or removed something on the host (`catalog.install` / `remove` / `update`, `mcp.upsert` / `remove`): every window re-reads that catalog. `models`: what `models.list` answers changed (an extension's provider or account model list). Not numbered: no session. |
| `sync:changed` | `{ kind: 'sync:changed' }` | A server's settings-sync record changed (a claim, a push, an override, a reset): settings UIs re-read `sync.status`. Not numbered: no session. |
| `tokens:changed` | `{ kind: 'tokens:changed' }` | A zidane-server's tokens changed (one created, revoked, paired, or the token file edited): token lists re-read `tokens.list`. Not numbered: no session. |
| `approval:request` | `{ kind: 'approval:request', request: WireApprovalRequest }` | A tool call waits for approval (safe mode). Not numbered: no session. |
| `approval:resolved` | `{ kind: 'approval:resolved', sessionId: string, requestId: string, decision: ApprovalDecision, answeredBy?: InteractionAnswerer }` | An approval left the pending queue (answered or denied in bulk); every OTHER window clears its surface. The window that resolved it already knows; the signal makes the queue converge everywhere else. |
| `sessions:changed` | `{ kind: 'sessions:changed', op: 'create' \| 'update' \| 'delete', sessionId: string }` | A session was created, updated or deleted. |
| `block-runs:changed` | `{ kind: 'block-runs:changed', sessionId: string }` | A session's saved block runs changed (a Run here ended, a run was removed): windows read them again. |
| `sessions:facets` | `{ kind: 'sessions:facets', sessionId: string, facets: Record<string, JsonValue> }` | A session's facet values changed (`ctx.sessions.facets`): every facet it holds now, keyed `<extensionId>.<key>`, the same as `SessionRow.facets`. Not a `sessions:changed`: the row's other fields stand. |
| `settings:changed` | `{ kind: 'settings:changed', settings: HostSettings }` | Settings were written through the host (`settings.set`); every window applies the new state so settings UIs converge without a restart. Windows that persist settings themselves must treat this as authoritative for the delivered snapshot. Not numbered: no session. |
| `draft:changed` | `{ kind: 'draft:changed', sessionId: string, text: string, shell?: boolean }` | Another window edited the session's shared prompt draft. The daemon EXCLUDES the originating peer from this fan-out, so applying it can never fight the local cursor; clients apply unconditionally (last-writer-wins whole-text replacement). Empty `text` clears the draft (broadcast when any window submits the prompt). |
| `draft:attachments` | `{ kind: 'draft:attachments', sessionId: string, attachments: PromptAttachment[] }` | The shared draft's composing ATTACHMENTS changed (paste/drop/remove in any window). Payloads ride along so a remote window can submit the shared draft attachments-and-all. Same origin-exclusion as text. |
| `search:changed` | `{ kind: 'search:changed', sessionId: string, active: boolean, query: string }` | The session's shared transcript-search state changed (mode entered / exited, query edited) in another window. Same origin-exclusion and last-writer-wins contract as the draft; every window on the session shows the same filtered transcript. |
| `suggest:changed` | `{ kind: 'suggest:changed', sessionId: string, seq: number, basis: string, caret: number, suggestion: string \| null, candidates?: string[] }` | The daemon resolved another window's inline-autocomplete query (`suggest.resolve`). Excludes the requesting peer (it already has the result from its RPC reply). Receivers mirror the ghost ONLY when their live buffer still equals `basis`; the standard staleness guard; otherwise discard. |
| `suggestion:ready` | `{ kind: 'suggestion:ready', sessionId: string, runId: string, text: string }` | The text model's guess at the user's next prompt after run `runId` (`Settings.promptSuggestions`). Windows offer it in the empty composer and drop it on the next `run:start`, a send, an edit or a compaction. |
| `run:estimate` | `{ kind: 'run:estimate', sessionId: string, estimate: RunEstimateView }` | A run of the session was estimated, or its wait on the user started or ended (`Settings.runEstimates`): the session's own run (`depth` 0) or a subagent's (`label`, `callId`). Saved on the run record too, so a window that missed it reads it from the session row's `runEstimates`. |
| `skills:snapshot` | `{ kind: 'skills:snapshot', sessionId: string, active: string[] }` | The daemon-side agent's active-skill set changed (`skills_use` / run-end deactivation). Windows mirror it into their skill chips. |
| `mcp:auth` | `{ kind: 'mcp:auth', sessionId: string, name: string, reason: string }` | A daemon-side MCP server needs (re-)authentication. Windows surface the badge; the interactive login flow itself runs in whichever window the user triggers it from (tokens land in the shared credential store; the daemon reconnects on the next agent rebuild). |
| `mcp:settled` | `{ kind: 'mcp:settled', sessionId: string }` | The session agent's MCP bootstrap finished: every server has its outcome (`mcp:status` / `mcp:auth` came first). A window's deadline for the one-shot auto-login it arms when the user adds a server. |
| `mcp:status` | `{ kind: 'mcp:status', sessionId: string, name: string, status: 'connected' } \| { kind: 'mcp:status', sessionId: string, name: string, status: 'failed', transport: string, error: string, code?: number }` | A host-side MCP server connected, or failed to. Windows feed it to the same status reducer the in-process path uses (badges: connected, signed in, needs sign-in, key refused, couldn't connect). `error` is the failure's message and `code` its HTTP-ish code when it had one, enough for the shared classifier (`classifyMcpTest`) to read it the same way. |
| `stream:retry` | `{ kind: 'stream:retry', sessionId: string, attempt: number, max: number, source: string, discardTurnId?: string }` | Provider retry in progress (rate-limit / overload / silent stall / dropped connection); drives the footer's "retrying n/m" indicator. `source` is `'parent'` or the retrying child's id; the indicator clears when that source streams again or the run ends. `discardTurnId`: the failed attempt had streamed reasoning, or text before its connection dropped, for that turn; windows drop it (`discardThinking`), since the retry streams its own and the saved turn never holds it. |
| `stream:retry:cleared` | `{ kind: 'stream:retry:cleared', sessionId: string, source: string }` | A retried stream reached a terminal state for `source` (`'parent'` or a child id); clear that source's retry indicator. Distinct kind rather than a nullable `attempt` so the two states stay unambiguous on the wire. Consumers also clear at run end as a backstop; this exists for the mid-run case, where a subagent's stream fails while the parent run continues and the indicator would otherwise stick. |
| `system-md` | `{ kind: 'system-md', sessionId: string, file: SystemMdFile \| null, shadowed: readonly SystemMdFile[] }` | Which `SYSTEM.md` override (if any) the host folded into this session's system prompt, emitted at agent-build time. The prompt is composed where the agent is built; in the daemon, not in the window; so a window cannot derive this by probing its own disk: it may have a different cwd, and `userInstructionsScope` is the daemon's. Windows need it to tell "no override on disk" apart from "override in force", which is the difference between doctrine the user wrote and doctrine they think they replaced. `shadowed` carries precedence losers so the Instructions surface can mark them inactive. |
| `cache:break` | `{ kind: 'cache:break', sessionId: string, info: CacheBreakInfo }` | A cache-prefix break was detected for this session's last request; the footer chip names the culprit dimension and the re-billed prefix size. Detection is per-agent closure state inside the loop, so in daemon mode only the daemon can observe it. Cost pricing stays window-side (it needs only the provider key and the token count). |
| `provider:diagnostic` | `{ kind: 'provider:diagnostic', sessionId: string, diagnostic: ProviderDiagnostic }` | A provider reported a diagnostic for the session. |
| `presence:changed` | `{ kind: 'presence:changed', sessions: SessionPresence[] }` | Presence snapshot; recomputed and fanned out whenever a window attaches/detaches or any tracked session's run/HITL state changes. Full snapshot (the set is small: only live sessions appear). Not numbered: no session. |
| `integrations:changed` | `{ kind: 'integrations:changed', integrations: IntegrationInfo[] }` | The host's integrations changed (`integrations.register` / `update`, or the client that registered one disconnected). Full list, like presence. Not numbered: no session. |
| `schedules:changed` | `{ kind: 'schedules:changed', scheduleId?: string }` | A schedule was created, changed, fired or removed (`scheduleId`), or several were. Clients read `schedules.list` again. Not numbered: no session. |
| `machine:status` | `({ kind: 'machine:status' } & MachineStatusChange)` | A machine of this host came online or went offline (`docs/MACHINES.md`). Not numbered: no session. |
| `machine:links` | `{ kind: 'machine:links', links: MachineLinksStatus }` | This local host's machine links changed (`machines.links.status`): shares of this computer, servers used as machines. Not numbered: no session. |
| `machine:notification` | `{ kind: 'machine:notification', method: string, params: JsonValue }` | A server lending itself as a machine to the local host on this connection (`machine.attach`): one of its machine notifications (`terminal.data`, `task.exit`, `exec.stream.*`). Sent to that connection only, never broadcast. Not numbered: no session. |
| `queue:changed` | `{ kind: 'queue:changed', sessionId: string, items: QueuedItem[] }` | The session's type-ahead queue changed (message queued while a run is in flight, drained by the host on run end, pushed early, or dropped). Broadcast to EVERY peer including the origin; the queue box is shared state and all windows render the same list. |
| `delivery:changed` | `{ kind: 'delivery:changed', sessionId: string, clientMessageId: string, state: DeliveryState, runId?: string }` | A message sent with a `clientMessageId` changed state (`DeliveryState`). `runId`: the run that started with it, took it, or ended with it. For integrations; windows ignore it. |
| `rpc.event` | `{ kind: 'rpc.event', envelope: RpcEventEnvelope }` | An RPC event (`RPC_EVENT_SIGNAL`). Not numbered: no session. |
| `extensions:ui` | `{ kind: 'extensions:ui', cwd: string, change: UiChange \| { type: 'reset' } }` | A UI item changed in `cwd`'s runtime. `reset`: the runtime was rebuilt, list again. Not numbered: no session. |
| `extensions:commands` | `{ kind: 'extensions:commands', cwd: string }` | The commands of `cwd`'s runtime changed (registered, retitled, rebound): list again. Not numbered: no session. |
| `extensions:keymap` | `{ kind: 'extensions:keymap', cwd: string, mode?: string }` | A keymap mode was pushed or popped. `mode`: the global mode on top now. Not numbered: no session. |
| `extensions:integrations` | `{ kind: 'extensions:integrations', cwd: string }` | An integration of `cwd`'s runtime registered, connected or disconnected. Not numbered: no session. |
| `extensions:integration-flow` | `{ kind: 'extensions:integration-flow', flowId: string, integrationId: string, step: IntegrationFlowStep }` | A step of a sign-in flow, to the client running it. Not numbered: no session. |
| `extensions:storage` | `{ kind: 'extensions:storage', extensionId: string, scope: StorageScope, sessionId?: string, projectRoot?: string, area: StorageArea, key: string, value?: Json, version?: number }` | A storage entry changed. `projectRoot` set for `project` and `session` scope. `value` absent: deleted. `version` counts the entry's writes on this host (see `WireStorageSetResult`). Not numbered: no session. |
| `extensions:entries` | `{ kind: 'extensions:entries', cwd: string }` | The extensions of `cwd` changed (loaded, reloaded, failed, unloaded): list `extensions.entries` again. Not numbered: no session. |
| `extensions:panel` | `{ kind: 'extensions:panel', cwd: string, extensionId: string, panelId: string, sessionId?: string }` | A panel of `cwd`'s runtime would load something new: its `subscribe` emitted, or its handle was updated. `panelId` is `<extension>/<id>`; `sessionId` set: only that session's content changed. Reload the panel where it is visible. Also sent as an `extensions:ui` `panel-changed` change, for clients that follow that one. Not numbered: no session. |
| `ui:dialog` | `{ kind: 'ui:dialog', dialogId: string, extensionId: string, sessionId?: string, request: DialogRequest }` | Show a dialog. Sent to the routed client only; answer with `ui.dialog.answer`. Not numbered: no session. |
| `ui:dialog:close` | `{ kind: 'ui:dialog:close', dialogId: string }` | Close a dialog the host gave up on (timeout, abort, unload). Not numbered: no session. |
| `ui:client` | `{ kind: 'ui:client', requestId?: string, request: UiClientRequest }` | A UI request for the routed client. `requestId` absent for `notify`. Not numbered: no session. |

## Reconnect and resume

The server keeps the latest signals of each session (2000 signals or 4 MiB per session, 32 MiB in total). Past the total, it drops the least recently written session first. A client that kept its last handshake's `signalEpoch` and the last `$seq` it applied asks for what it missed:

```json title="request"
{
  "jsonrpc": "2.0",
  "id": 9,
  "method": "sessions.resume",
  "params": {
    "sessionId": "7f6b2bc1-27b2-4052-a2b7-de05d2538178",
    "epoch": "ed173759-2d4f-436a-b39a-36c1c969e088",
    "afterSeq": 43
  }
}
```

```json title="response"
{
  "jsonrpc": "2.0",
  "id": 9,
  "result": {
    "ok": true,
    "signals": [
      {
        "seq": 44,
        "signal": {
          "kind": "plans:snapshot",
          "sessionId": "7f6b2bc1-27b2-4052-a2b7-de05d2538178",
          "snapshot": {
            "plans": [],
            "active": null
          }
        }
      },
      {
        "seq": 45,
        "signal": {
          "kind": "run:end",
          "sessionId": "7f6b2bc1-27b2-4052-a2b7-de05d2538178",
          "runId": "6f577015-ffbf-4204-ba81-320f71e76f46",
          "ok": true
        }
      }
    ]
  }
}
```

The answer is `{ ok: false, reason }` when the log cannot fill the gap. The reason is `epoch` (another server process, after a restart) or `gap` (the log dropped those signals). The client then reloads the transcript with `sessions.loadEvents`.

```json
{
  "jsonrpc": "2.0",
  "id": 10,
  "result": {
    "ok": false,
    "reason": "epoch"
  }
}
```

To resume without applying a signal twice:

1. Hold the live signals of the session while `sessions.resume` runs.
2. Apply the replay.
3. Apply the held signals that the replay did not carry.

The apps reconnect with a backoff from 1 to 30 seconds. `createServerHost` uses 0.5 to 15 seconds. A refused token or another server at the URL stops the retries.

## Paging

`sessions.loadEvents` without `before` or `limit` returns the whole transcript as an array. With either, it returns the last `limit` turns (default 200, at most 1000) before turn position `before`. Pass the `cursor` of the page as the next `before`. A page without `cursor` holds the first turn. A page starts on an exchange boundary, so a tool call always comes with its result. `start` counts the events of the transcript before the page.

```json title="request"
{
  "jsonrpc": "2.0",
  "id": 8,
  "method": "sessions.loadEvents",
  "params": {
    "sessionId": "7f6b2bc1-27b2-4052-a2b7-de05d2538178",
    "limit": 2
  }
}
```

```json title="response"
{
  "jsonrpc": "2.0",
  "id": 8,
  "result": {
    "events": [
      {
        "kind": "user-prompt",
        "text": "Say hello.",
        "turnId": "57942b84-c9cb-42c4-be2b-79aa9fc2171e"
      },
      {
        "kind": "markdown",
        "text": "Hello from the scripted provider.",
        "streaming": false,
        "turnId": "999068c5-c52b-4ccd-a936-682bea162657"
      },
      {
        "kind": "run-summary",
        "text": "Mock · <1s · ↻1 · ↑10 ↓33 · 33000 tk/s",
        "runSummary": {
          "modelId": "mock",
          "durationMs": 10,
          "turns": 2,
          "tokensIn": 10,
          "tokensOut": 33,
          "tokensPerSecond": 33000
        },
        "turnId": "999068c5-c52b-4ccd-a936-682bea162657"
      }
    ],
    "exact": true,
    "start": 1
  }
}
```

A page over the message size comes back as `ResponseTooLarge`. If that happens, ask again with a smaller `limit`. `sessions.getTurns` pages the raw turns the same way (`from`, `limit`).

## Errors

Errors are JSON-RPC error objects. A handler that throws becomes `HandlerFailed` with its message:

```json
{
  "jsonrpc": "2.0",
  "id": 13,
  "error": {
    "code": -32014,
    "message": "Session \"no-such-session\" not found. Create the session before sending a prompt."
  }
}
```

```json
{
  "jsonrpc": "2.0",
  "id": 11,
  "error": {
    "code": -32601,
    "message": "Unknown method \"sessions.nope\"."
  }
}
```

```json
{
  "jsonrpc": "2.0",
  "id": 12,
  "error": {
    "code": -32602,
    "message": "params must be an object."
  }
}
```

```json
{
  "jsonrpc": "2.0",
  "id": 15,
  "error": {
    "code": -32040,
    "message": "\"machine.register\" needs a machine connection (a machine-scoped token); this is a client connection."
  }
}
```

| Code | Name | Kind | Description |
| - | - | - | - |
| `-32700` | `ParseError` | JSON-RPC | The received text is not valid JSON. |
| `-32600` | `InvalidRequest` | JSON-RPC | The message is not a valid JSON-RPC request. |
| `-32601` | `MethodNotFound` | JSON-RPC | The method does not exist. |
| `-32602` | `InvalidParams` | JSON-RPC | The method params are invalid. |
| `-32603` | `InternalError` | JSON-RPC | The handler failed with an unexpected error. |
| `-32010` | `VersionMismatch` | IPC | The `ipc.hello` protocol versions differ. The message names both and the side to upgrade; `data.daemon` carries the host's hello fields. |
| `-32011` | `NotHandshaken` | IPC | A method other than `$/ping`, `daemon.info` or `daemon.shutdown` before `ipc.hello`. |
| `-32012` | `RateLimited` | IPC | A network transport's per-connection request budget is spent; retry later. |
| `-32013` | `ResponseTooLarge` | IPC | The reply would exceed the transport's message size: nothing was sent but this error (the connection stays). Ask for less (a transcript: a smaller page, see `loadAllEvents`). |
| `-32014` | `HandlerFailed` | IPC | The handler threw: the host refused or failed the call (`message` says why). Hosts before it answered -32000, which ACP uses for `AuthenticationRequired`; clients treat both as a plain remote error. |
| `-32015` | `Forbidden` | IPC | The connection's token doesn't reach this method (`./scopes`: a `chat` token calling an `admin` method). `data.required` names the scope it needs. Additive: hosts before scopes never refuse this way. |
| `-32040` | `MethodNotAllowed` | Machine | A machine connection called a client method, or a client a machine one. |
| `-32041` | `VersionSkew` | Machine | `machine.register` with a `protocolVersion` other than this host's `MACHINE_PROTOCOL_VERSION`. |
| `-32042` | `NameTaken` | Machine | The name is held by another machine on this host. |
| `-32043` | `OutsideRoots` | Machine | A path or cwd outside the machine's roots, or in its data dir. |
| `-32044` | `IdBound` | Machine | The machine id is bound to another (live) token, or this token was used by the machine before the host forgot it. |
| `-32045` | `Busy` | Machine | No room now (a capacity limit): try again later, unlike a refusal. |

## Constants

| Constant | Value | Description |
| - | - | - |
| `IPC_PROTOCOL_VERSION` | `16` | Protocol version a client sends in `ipc.hello`; a server accepts only its own. |
| `WS_SUBPROTOCOL` | `zidane` | The subprotocol clients offer and the server selects. |
| `WS_BEARER_PREFIX` | `zidane.bearer.` | Prefix of the subprotocol entry that carries the token, for clients that cannot set headers. |
| `WS_REFUSAL_HEADER` | `X-Zidane-Refusal` | Response header naming why an upgrade got a 403. |
| `PING_METHOD` | `$/ping` | Client heartbeat request, answered before the handshake too. |
| `SIGNAL_SEQ_FIELD` | `$seq` | Field on a session signal's params: its number in the session, from 1, gap-free within one server process. |
| `WS_HEARTBEAT_MS` | `20000` | Ping interval of both ends by default, in ms. |
| `HEARTBEAT_MISSES` | `2` | Unanswered pings in a row before a connection is dropped. |
| `WS_MAX_MESSAGE_BYTES` | `33554432` | Largest message by default (`maxMessageBytes`). |
| `PEER_WRITE_HIGH_WATERMARK` | `8388608` | Bytes of pushed signals that may wait for a client that stopped reading before it is dropped. |
| `LOAD_EVENTS_PAGE_TURNS` | `200` | Turns per `sessions.loadEvents` page when only `before` is given. |
| `LOAD_EVENTS_MAX_PAGE_TURNS` | `1000` | Largest `limit` a host honors. |
| `SERVER_HEALTH_PATH` | `/healthz` | Unauthenticated liveness endpoint on the server's port. |
| `SERVER_PAIR_PATH` | `/zidane/pair` | Where a browser posts a pairing code (`POST`, JSON) and gets a device token back. |
| `MAX_MINTED_MACHINE_TOKENS` | `16` | Live machine tokens one client token may mint with `tokens.createMachineToken`. |

## Methods

Grouped by the part before the dot. Params and result are the method map's TypeScript types, exported from [`zidane/ipc`](https://zidane.si/docs/reference/ipc.md).

### chat

| Method | Params | Result | Description |
| - | - | - | - |
| `chat.send` | `Omit<SendOptions, 'wake'> & SendRequestId` | `{ runId: string, consumed?: 'handled' \| 'steer' \| 'followUp' }` | `wake` is host-internal (see `SendOptions.wake`); not accepted over the wire. `requestId`: see `SendRequestId`. The prompt goes through the extensions' prompt transforms and `prompt` interceptors of the session's folder first, once per submission, on the host (clients never run them). `consumed` says one took it instead of a run (`runId` is then `''`): `handled` (an extension consumed it), `steer` / `followUp` (delivered to the live run). Additive. |
| `chat.interrupt` | `{ sessionId: string }` | `void` | Aborts the session's live run. Does nothing when no run is live. |
| `chat.pushQueued` | `{ sessionId: string, message: string, activatedSkills?: string[], keepDraft?: boolean, origin?: SendOptions['origin'], literal?: boolean, clientMessageId?: string, metadata?: SendOptions['metadata'] } & SendRequestId` | `{ delivered: 'steer' \| 'send' \| 'command' \| 'queued', consumed?: 'handled' \| 'steer' \| 'followUp' }` | Delivers a queued message. Steers a live run, otherwise starts a run or runs it as a command. `delivered` says which path it took. `keepDraft` keeps the shared composer draft instead of clearing it on the other clients. The message goes through the `prompt` interceptors first, as `chat.send`; `consumed` says one took it (`delivered` is then `send` for `handled`, `steer` otherwise). Additive. |
| `chat.isRunning` | `{ sessionId: string }` | `boolean` | Resolves `true` while the session has a live run. |
| `chat.rebuildAgent` | `{ sessionId: string }` | `void` | Retire the session's live agent so the next run rebuilds against fresh config/credentials (MCP login, extension reload). Run-end deferred when a run is in flight; HITL/queue state survives. |
| `chat.listWork` | `{ sessionId: string, children?: boolean }` | `RunningWorkItem[]` | Lists the tool calls and background tasks the session's live run tracks and can cancel. Empty when no run is live. `children: true` adds the subagents' running calls (`childId` set), for their starts: a transcript read from disk times them too. Older hosts ignore it. |
| `chat.cancelWork` | `{ sessionId: string, id: string \| 'all', reason?: string }` | `boolean` | Cancels one tracked work item by `id`, or every visible item with `"all"`. Resolves `true` when something was cancelled. `reason` is passed to a cancelled tool call. |
| `chat.deliveryState` | `{ sessionId: string, requestId?: string, clientMessageId?: string }` | `{ state: DeliveryState \| 'unknown', runId?: string, settled: boolean }` | What became of a send, by its `requestId`, its `clientMessageId`, or both: the send request record, then the last announced `delivery:changed` state, then the queue and the transcript (`origin.messageId`). Resolves a send answered "already accepted" (`SendRequestPendingError`). `state`: the last known `DeliveryState`, or `unknown` when the host holds no trace. `runId`: the run that took it, when known. `settled`: nothing more will happen to it: a final state (`answered`, `failed`, `interrupted`, `dropped`), or, with `unknown`, it is in neither the queue nor the transcript and the send request record says no send of it runs (it never landed: send it again with a new `requestId`); without a `requestId` an `unknown` is never settled. Additive. |

### sessions

| Method | Params | Result | Description |
| - | - | - | - |
| `sessions.setLaunchOverrides` | `{ sessionId: string } & SessionLaunchOverrides` | `void` | `zidane run --system / --mcp` for a host-backed window: launch overrides for the session's agent (`ChatHost.setLaunchOverrides`), owned by the calling client and dropped when it disconnects. A call with neither field clears them. |
| `sessions.list` | `{ projectRoot?: string \| null, archive?: 'active' \| 'archived' \| 'all' }` | `SessionRow[]` | Lists session rows. `projectRoot` filters by project, `archive` picks active, archived or all sessions. |
| `sessions.listMeta` | `{ projectRoot?: string \| null }` | `SessionMeta[]` | The session list as a window renders it (`SessionMeta`: title, turn / message / run counts, project, cwd, environment, artifact count, the orchestration main flag), newest first. `projectRoot`: omitted = every project, `null` = untagged sessions. |
| `sessions.getMeta` | `{ sessionId: string }` | `SessionMeta \| null` | One `SessionMeta` by id (rows a filtered list can't return, e.g. the main); `null` when unknown. |
| `sessions.get` | `{ sessionId: string, turns?: boolean }` | `SessionDocumentHead \| null` | The whole session document as the host holds it: the live copy when an agent is live (turns persisted mid-run, todos kept in memory), else the stored one, a stale `running` marker demoted. `null` when unknown. |
| `sessions.getTurns` | `{ sessionId: string, from?: number, limit?: number }` | `{ turns: SessionTurn[], total: number, version?: string } \| null` | A slice of a session's turns, `[from, from + limit)` (default from 0, `SESSION_TURNS_PAGE` turns, at most `SESSION_TURNS_MAX_PAGE`), from the same copy `sessions.get` reads, and how many turns it has: a client reads a long session's document a page at a time (`loadSessionDocument`) instead of in one reply. With `sessions.get` `turns: false` (the document without its turns), what an export needs. Pages and that head come from one snapshot of the document per content version (read once, not per page), and name it (`version`): a client that sees it change mid-load starts over rather than mix two versions (`loadSessionDocument`). `null` when unknown. Additive: an older host answers "method not found", ignores `turns: false`, and names no version. |
| `sessions.getToolResult` | `{ sessionId: string, callId: string, turnId?: string }` | `string \| null` | The full text of one tool result (`callId`; `turnId`, when known, the turn that holds it): what a clipped tool row shows on expand, without the session crossing the wire. `null` when no such result. Additive. |
| `sessions.getHeader` | `{ sessionId: string, usage?: boolean }` | `SessionHeader \| null` | One session's header: its list row plus metadata, profile and status, without turns or runs (the live copy when an agent holds it). What a window re-reads on `sessions:changed`. Additive. `usage: true` adds the footer's numbers, computed host-side from the turns and runs (`contextTokens`, `costUsd`, and while the host runs the session `runStartedAt` / `runOutputTokens`): a client needs no document for its context gauge, cost pill or run readout. Older hosts ignore it. |
| `sessions.setExtensionState` | `{ sessionId: string, extension: string, key: string, value?: JsonValue }` | `void` | One key of an extension's per-session state (`metadata.extensions`, the bag `ctx.session` and renderer `useSessionState` share), written by the host into its copy of the session. `value` omitted deletes the key. |
| `sessions.create` | `CreateSessionParams` | `SessionRow` | Creates a session and resolves with its row. |
| `sessions.delete` | `{ sessionId: string }` | `{ environment?: EnvironmentTeardownResult }` | Resolves with what happened to the session's environment (a provider may keep it). |
| `sessions.sideSession` | `{ sessionId: string }` | `SideSessionLookup` | A session's `/side` session (`sessions.create` with `sideOf`; hidden from `sessions.list`), the newest when there are several, with the session's own row (`null`: unknown). Additive. |
| `sessions.retryEnvironment` | `{ sessionId: string }` | `void` | Provision a failed (or restart-stranded) environment again. |
| `sessions.moveEnvironment` | `{ sessionId: string, environment: EnvironmentRequest, cwd?: string, removeOldWorktree?: boolean }` | `{ oldWorktree?: EnvironmentTeardownResult }` | Move an idle session to another environment (`local`, `worktree`, `local@M`, `worktree@M`, any direction), in `cwd` on the target; provisioning continues in the background. |
| `sessions.environmentActions` | `{ sessionId: string }` | `EnvironmentActionSummary[]` | The per-session commands the session's environment offers right now (push, open PR, …). |
| `sessions.runEnvironmentAction` | `{ sessionId: string, actionId: string }` | `EnvironmentActionResult` | Run one of them in the session's environment (resumed when suspended). |
| `sessions.workspaceExec` | `{ sessionId: string, command: string, cwd?: string, env?: Record<string, string>, timeoutSeconds?: number }` | `ExecResult` | The session's workspace (`ChatHost.workspace*`): its execution context, rooted inside the environment when the files live there, else at the session's cwd. Paths and exec `cwd` resolve against the root as the execution context resolves them; containment is the context's own. `cwd` / `env` on exec and the list / size methods are additive: an older daemon ignores the former and answers "method not found" to the latter (active clients are build-fenced, so this is a transient case). |
| `sessions.workspaceExecStream` | `{ sessionId: string, command: string, cwd?: string }` | `{ execId: string }` | The same command, streamed (`!` on a remote host): the reply names the exec, its output follows as `workspace:exec:data` signals and ends with one `workspace:exec:exit`, all to this connection only (signals may precede the reply). Bounded per connection (concurrent commands, chunk size, total output: past it the process is stopped and the exit says `truncated`). A dropped connection kills what it started. Additive: an older host answers "method not found" and clients use `workspaceExec`. |
| `sessions.workspaceExecCancel` | `{ execId: string }` | `{ cancelled: boolean }` | Kill a streamed command's process group (this connection's only; `cancelled: false` otherwise or when it already ended). |
| `sessions.runBlock` | `{ sessionId: string, turnId: string, code: string, language: string }` | `{ runId: string }` | Runs a shell block of a reply (Run here) in the session's folder, with no input. The reply names the run; its output follows as `block-run:data` signals and ends with one `block-run:end` carrying the saved run, to this connection only. Nothing enters the conversation; the run becomes the block's last (`sessions.blockRuns`), announced to every window by `block-runs:changed`. A dropped connection stops what it started. |
| `sessions.runBlockCancel` | `{ runId: string }` | `{ cancelled: boolean }` | Stops a Run here this connection started (`cancelled: false` when it is not running). |
| `sessions.blockRuns` | `{ sessionId: string }` | `{ runs: Record<string, BlockRun> }` | The session's saved block runs, by key (`blockRunKey`). |
| `sessions.removeBlockRun` | `{ sessionId: string, key: string }` | `void` | Forgets a block's saved run. |
| `sessions.workspaceReadFile` | `{ sessionId: string, path: string }` | `string` | Reads a text file from the session's workspace. |
| `sessions.workspaceWriteFile` | `{ sessionId: string, path: string, content: string }` | `void` | Writes a text file in the session's workspace. |
| `sessions.workspaceListFiles` | `{ sessionId: string, path: string, options?: WorkspaceListFilesOptions }` | `ListFilesEntry[]` | Lists the entries under `path` in the session's workspace. |
| `sessions.workspaceFileSize` | `{ sessionId: string, path: string }` | `number \| null` | Size in bytes of a file in the session's workspace, or `null` when it does not exist. |
| `sessions.workspaceCreate` | `{ sessionId: string, path: string, kind: 'file' \| 'dir', content?: string, freeName?: boolean }` | `EntryResult` | Creates a file (`content`: base64 bytes, default empty) or a folder; `freeName` takes the next free name instead of refusing a taken one. |
| `sessions.workspaceMove` | `{ sessionId: string, from: string, to: string }` | `EntryResult` | Moves (renames) `from` to `to`. |
| `sessions.workspaceCopy` | `{ sessionId: string, from: string, toDir: string }` | `EntryResult` | Copies `from` into folder `toDir` (`''`: the root) under a free name. |
| `sessions.workspaceRemove` | `{ sessionId: string, path: string }` | `EntryResult` | Removes `path` (a folder whole), for good. |
| `sessions.workspaceEmptyDirs` | `{ sessionId: string }` | `string[]` | The workspace's empty folders, relative to its root (a file listing leaves them out), capped. |
| `sessions.setTitle` | `{ sessionId: string, title: string }` | `void` | Renames a session. |
| `sessions.generateTitle` | `{ sessionId: string, providerKey?: string, model?: string }` | `{ title: string }` | Title the session ON the host: its providers and credentials (a server session's transcript never goes to the window's provider), the same generator (`generateSessionTitle`), written as `sessions.setTitle` does. `providerKey` / `model`: the window's pick, used when the host has that provider; otherwise the session's own provider and model. The Main session is refused (it keeps its name). Additive: an older host answers "method not found". |
| `sessions.titleStatus` | `{ sessionId: string }` | `string[]` | How the session's title came about, as the session details dialog words it (`describeTitleStatus`): where it came from (`auto`, `typed by you`, `untitled`, …), then the last automatic titling attempt when there was one (`generated 2h ago`, `check failed 5m ago (…)`). Additive. |
| `sessions.setArchived` | `{ sessionId: string, archived: boolean }` | `void` | Archives or restores a session. |
| `sessions.setFavorite` | `{ sessionId: string, favorite: boolean }` | `void` | Keep the session at the top of lists (`metadata.favorite`). Not activity: no recency bump. Additive. |
| `sessions.setPolicy` | `{ sessionId: string, policy: SessionPolicy }` | `void` | Replaces the session's rules (`metadata.policy`, `SessionPolicy`); `{}` clears them. Loosening the current rules (a looser `approvalMode`, 'ask' > 'decide' > 'off', removing a rule, raising a ceiling) needs an approve token. Additive. |
| `sessions.setSpace` | `{ sessionId: string, spaceId: string }` | `void` | Move the session to a space (`metadata.spaceId`); the Default space id clears it. See `chat/spaces`. |
| `sessions.setAgentId` | `{ sessionId: string, agentId: string }` | `void` | Switches the agent the session runs as. |
| `sessions.setEffort` | `{ sessionId: string, effort: ThinkingLevel }` | `void` | Sets the thinking effort of the session. |
| `sessions.setModel` | `{ sessionId: string, providerKey: ProviderKey, modelId: string, account?: string, fallbacks?: ModelFallbackRef[], effort?: ThinkingLevel, modelOptions?: Record<string, boolean> }` | `void` | The one model-swap contract (`ChatHost.setSessionModel`). A pick is a whole: `fallbacks` (ordered, primary excluded) REPLACES the persisted `metadata.modelChain` (omitted/empty clears it); `effort` and `modelOptions` are reset to the supplied values (omitted clears them; per-model knobs never leak onto the new model). The live agent is retired so the next run rebuilds on the new pick (run-end deferred while a run is in flight). |
| `sessions.setModelOptions` | `{ sessionId: string, options: Record<string, boolean> }` | `void` | Replaces the model-specific boolean options of the session. |
| `sessions.loadEvents` | `{ sessionId: string }` | `StreamEvent[]` | The session's transcript as canonical events. Without `before` / `limit`: every turn, as a plain array (the shape every client predating paging reads). With either: one page, `{ events, cursor? }`, the last `limit` turns (default `LOAD_EVENTS_PAGE_TURNS`, at most `LOAD_EVENTS_MAX_PAGE_TURNS`) before turn position `before` (default: the end), its start moved back to an exchange boundary when one is near so a tool call is never cut from its result. `cursor`: the `before` of the previous page, absent at the first turn. Typed here as the unpaged call every client makes; a paged call goes through `loadEventsPage`. Paging is additive: an older host ignores the fields and answers the whole array. |
| `sessions.resume` | `{ sessionId: string, epoch: string, afterSeq: number }` | `SessionResumeResult` | Reattach without a reload: the session's signals numbered after `afterSeq` (see `SIGNAL_SEQ_FIELD`), in order, as long as this host process (`epoch`, from `HelloResult.signalEpoch`) still holds them all. Otherwise refused, and the client reloads with `sessions.loadEvents`. Additive: an older host answers "method not found", same fallback. |
| `sessions.getTurn` | `{ sessionId: string, turnId: string }` | `SessionTurn \| null` | One turn of a session by id, or `null` when unknown. |
| `sessions.contextBreakdown` | `{ sessionId: string }` | `ContextBreakdown \| null` | The session's context usage broken down by category, or `null` when unknown. |
| `sessions.artifacts` | `{ sessionId: string, kind?: string, limit?: number }` | `SessionArtifact[]` | Artifacts one session recorded (PRs, branches, commits, files, extension kinds; see `session/artifacts.ts`), newest first. Empty on stores without artifact support. Additive; pre-artifact daemons reject the method and clients treat that as empty. |
| `sessions.touch` | `{ sessionId: string }` | `void` | A window appended transcript content OUT-OF-BAND (local `/` command output, `!` shell turns; written straight to the shared store, not through a host run). The daemon fans out `sessions:changed` so every window (issuer included) re-converges on the canonical replay NOW instead of at the next run end. Sent as a notification. |
| `sessions.fork` | `{ sessionId: string, turnId: string, title?: string }` | `{ id: string }` | Fork at `turnId` into a new session (`ChatHost.forkSession`); announced as a `sessions:changed` create. |
| `sessions.deleteTurn` | `{ sessionId: string, turnId: string }` | `void` | History edits (`ChatHost.deleteTurn` / `editTurnText`): refused while the session runs. |
| `sessions.editTurnText` | `{ sessionId: string, turnId: string, text: string }` | `void` | Replaces the text of one turn. Refused while the session runs. |
| `sessions.rewind` | `{ sessionId: string, turnId: string, inclusive: boolean }` | `void` | Cut the history at `turnId` in place (`ChatHost.rewindSession`); `inclusive` keeps the turn. Refused while the session runs. |
| `sessions.setFolder` | `{ sessionId: string, cwd: string }` | `void` | Bind to a folder, or back to a plain chat with `cwd: ''` (`ChatHost.setSessionFolder`). |
| `sessions.appendTurns` | `{ sessionId: string, turns: SessionTurn[], whenIdle?: boolean }` | `void` | Turns written outside a run, e.g. `!` shell output (`ChatHost.appendTurns`). `whenIdle`: refused while a run is live or an interrupted run waits (a channel's context messages). |
| `sessions.appendNote` | `{ sessionId: string, blocks: CommandOutputBlock[] }` | `boolean` | `ctx.appendNote` outside a run: a display-only note, rendered live and persisted (`ChatHost.appendNote`). False for an unknown session or no blocks. |
| `sessions.compact` | `{ sessionId: string }` | `CompactionReport \| null` | Compact now (`ChatHost.compactSessionNow`): what was done, `null` when nothing was. Older daemons resolve `null`. |
| `sessions.systemMd` | `{ sessionId: string }` | `{ file: SystemMdFile \| null, shadowed: readonly SystemMdFile[] } \| null` | Which `SYSTEM.md` override composes this session's prompt, for windows attaching to an already-warm agent. The `system-md` signal fires once per agent BUILD and the daemon caches agents, so the normal multi-window case (daemon already running, open a second terminal or the GUI) would otherwise never learn that the built-in safety and git doctrine has been replaced. `null` = no live agent yet, in which case the next build broadcasts it. |
| `sessions.contextSegments` | `{ sessionId: string }` | `ContextSegmentsReport \| null` | Every chunk of the session's system prompt with provenance; the Instructions tab. Built from the live agent's assembly, so it reflects MCP instructions, extension fragments and the resolved `SYSTEM.md`. |
| `sessions.cacheImpact` | `{ sessionId: string, change: CacheImpactChange }` | `CacheImpact \| null` | What a switch would cost the session's prompt cache, as the host applies it (`ChatHost.cacheImpact`): what pickers show next to each option. `null` without a live agent or for an empty change. Additive: an older host answers "method not found", and clients show no estimate. |
| `sessions.cacheReport` | `{ sessionId: string, limit?: number }` | `CacheReport \| null` | The session's last requests and what the cache did for each (`ChatHost.cacheReport`): what `/cache` prints, for the GUI's session details. `null` for an unknown session. Additive: an older host answers "method not found". |
| `sessions.export` | `{ sessionId: string, format: SessionExportFormat }` | `SessionExportFile` | The session's transcript rendered as a file to hand to the user: Markdown (title, metadata, every turn) or the session's JSON. Nothing is written on the host. Additive (no version bump). |
| `sessions.createSystemOverride` | `{ sessionId: string }` | `{ path: string, created: boolean }` | Seed a user-scope `SYSTEM.md` from the session's doctrine; throws when an override could not apply. |

### runtime

| Method | Params | Result | Description |
| - | - | - | - |
| `runtime.reload` | `Record<string, never>` | `void` | Drop the daemon's cached runtime configs (extension registries, per cwd) and retire every live agent so the next command or run rebuilds against what is on disk now. Sent by a window after it reloaded its own extensions (install, update, remove, `/reload`): extension `/` commands execute daemon-side, so without this the daemon keeps running the code it loaded at boot. Busy sessions rebuild at run end. |

### environments

| Method | Params | Result | Description |
| - | - | - | - |
| `environments.list` | `{ cwd?: string, kind?: string }` | `EnvironmentDescriptor[]` | Every environment kind the host can create, described for a new session in `cwd` (availability, default, branch suggestion, options; secrets only as "configured"). What pickers and the Environments settings render. `kind`: a machine kind (`local@laptop`) says `cwd` is that machine's folder, checked against its roots instead of this host's (`docs/MACHINES.md` § Folders); the same on `commands.*`, `catalog.*` and `models.list`. Additive. |
| `environments.update` | `EnvironmentSettingsChange` | `void` | Apply one Environments-settings edit (enable, default, options, secrets), validated against the kind's schema. Settings changes fan out as `settings:changed`; secrets are stored host-side and never returned. |
| `environments.verify` | `{ kind: string }` | `EnvironmentVerifyResult` | "Test connection" for a kind with its current options. |

### artifacts

| Method | Params | Result | Description |
| - | - | - | - |
| `artifacts.list` | `{ projectRoot?: string \| null, kind?: string, uri?: string, since?: number, limit?: number }` | `SessionArtifact[]` | Artifacts across sessions, filtered by project root (`null` = untagged sessions, omitted = every project) and/or kind/uri (reverse lookup). |
| `artifacts.delete` | `{ sessionId: string, artifactId: string }` | `boolean` | Remove one artifact. `false` when the store has no artifact support. Fans out `artifact:changed`. |
| `artifacts.upsert` | `{ artifact: SessionArtifact }` | `SessionArtifact \| null` | Upsert an artifact; the lazy `resolve()` write-back (refreshed `label` / `meta`). Same merge semantics as `SessionStore.upsertArtifact`; `null` when the session is unknown or the store lacks support. |
| `artifacts.refresh` | `{ sessionId: string, artifactId: string, force?: boolean }` | `SessionArtifact \| null` | Re-resolves one stored artifact (is the pull request merged?): its kind's `resolve()` runs on the host, with the extensions of the folder the artifact landed in. A change is stored and fans out `artifact:changed`. `force` asks past the kind's own freshness window. `null` when nothing changed (no resolver, nothing new, an unknown artifact). Additive. |
| `artifacts.badges` | `{ sessionIds: string[] }` | `Record<string, SessionBadgeEntry[]>` | The badges under sessions' titles (commits, pull requests, extension kinds with a `badge`), for up to `ARTIFACT_BADGES_MAX` sessions at once, keyed by session id. Each session's badges come from the extensions of the folder its newest artifact landed in. They change with the session's `artifact:recorded` / `artifact:changed` and with `catalog:changed` (`extensions`): read them again then. Additive. |

### history

| Method | Params | Result | Description |
| - | - | - | - |
| `history.searchSession` | `{ sessionId: string, query: string, limit?: number }` | `SearchTurnResult[]` | Transcript search over ONE session, daemon-side. The daemon owns the authoritative turns (live wrapper when an agent is active; fresh mid-run, which a window's load-time snapshot is not) and an LRU index cache shared by every window, so repeat queries pay match cost only. Results come back in TURN ORDER (search is a transcript filter). |
| `history.searchAll` | `{ query: string, sessionIds: string[], excerptLimit?: number }` | `Record<string, SessionHistoryMatch>` | Cross-session content search, daemon-side. One RPC replaces a window's per-session index/build/search round-trips; indexes are built once in the daemon and shared by every window. |

### plans

| Method | Params | Result | Description |
| - | - | - | - |
| `plans.get` | `{ sessionId: string }` | `PlansSnapshot \| null` | The session's plans (from its live agent, else the store), or `null` for an unknown session. |
| `plans.edit` | `{ sessionId: string, planId: string, op: PlanEditOp }` | `PlansSnapshot` | A user edit to a plan's steps: rename, add or cancel (a step is never deleted). Through the live agent's session, else the store; refused while another process runs the session. The host fans `plans:snapshot` out to every window; the result is that snapshot. A refused edit is an error carrying the gate's reason. |
| `plans.control` | `{ sessionId: string, planId: string, action: 'stop' \| 'drop' }` | `PlansSnapshot` | Stop or drop a plan. Stopping or dropping the plan a live run applies interrupts that run. Resume is a message (`buildPlanResumeMessage`), not a call. |

### orchestration

| Method | Params | Result | Description |
| - | - | - | - |
| `orchestration.main` | `Record<string, never>` | `string \| null` | The main session id, creating it (directory-less) when the setting is on and none exists. |
| `orchestration.createWorker` | `OrchestrationCreateParams` | `string` | Creates a session (optionally holding orchestration tools and a subscription) and resolves with its id. |
| `orchestration.list` | `{ limit?: number }` | `OrchestrationWorkerRow[]` | Lists sessions with their live status, newest first, up to `limit`. The list can be partial. |
| `orchestration.read` | `{ sessionId: string, full?: boolean }` | `OrchestrationWorkerSummary` | Summary of one session (with its `link`); rejects when unknown. `lastMessage` is cut at 600 characters; `full: true` returns it whole. |
| `orchestration.send` | `{ sessionId: string, prompt: string }` | `boolean` | Queues a prompt as the session's next turn; `false` when declined (the person stopped the session, or their queued prompts go first). |
| `orchestration.steer` | `{ sessionId: string, message: string, attachments?: PromptAttachment[] }` | `void` | Sends a steering message, with optional attachments, to a session's live run. |
| `orchestration.cancel` | `{ sessionId: string }` | `void` | Cancels a session's run. |
| `orchestration.transcript` | `{ sessionId: string, limit?: number, full?: boolean }` | `string` | The last `limit` turns of a session as plain text, each cut at 1200 characters; `full: true` returns them whole. |
| `orchestration.wait` | `{ sessionId: string, timeoutSec?: number }` | `OrchestrationWaitResult` | Resolves once the session is idle or blocked, or after `timeoutSec` (default 30, at most 50). |
| `orchestration.events` | `OrchestrationEventQuery` | `SessionEvent[]` | Searches the recent session events (runs ended, sessions blocked), newest first. |
| `orchestration.pending` | `{ sessionId?: string }` | `OrchestrationPendingRequest[]` | The prompts waiting for an answer (questions, plans, tool approvals), of one session or all. |
| `orchestration.answer` | `{ requestId: string, response: InteractionResponse }` | `boolean` | Answers a question or a plan, recording the connection as `answeredBy`. `false` when it was answered first. |
| `orchestration.approve` | `{ requestId: string, decision: ApprovalDecision }` | `boolean` | Decides a tool approval (an `approve` token), recording the connection as `answeredBy`. `false` when it was decided first. |

### commands

| Method | Params | Result | Description |
| - | - | - | - |
| `commands.list` | `{ cwd: string, kind?: string }` | `CommandSummary[]` | Extension-contributed `/` commands discovered for `cwd`. |
| `commands.completeArgs` | `{ cwd: string, name: string, argsPrefix: string, kind?: string }` | `CommandArgSuggestionSummary[]` | Argument suggestions for `/name <prefix>`; best-effort, empty on failure. |

### agents

| Method | Params | Result | Description |
| - | - | - | - |
| `agents.list` | `{ cwd: string }` | `HostAgentProfile[]` | The agent profiles `cwd` offers (`''`: no folder), as the agent picker lists them: a profile a setting turns off (Codemode) is left out. Additive. |

### completions

The extensions' composer completions, run on the server: the triggers a folder offers, a provider's items, and the submit hooks on a prompt about to be sent.

| Method | Params | Result | Description |
| - | - | - | - |
| `completions.providers` | `{ cwd: string }` | `ExtensionCompletionProviderSummary[]` | The extension completion providers (`ctx.registerCompletionProvider`) of `cwd` a composer can render: a one-character trigger each, `@` `/` `#` left to the built-in completers, first wins on id and trigger. Additive. |
| `completions.suggest` | `{ cwd: string, providerId: string, query: string, text: string, cursor: number }` | `ExtensionCompletionItemSummary[]` | One provider's items for `query` (the text after its trigger), at most 8. A newer call for the same provider on the connection aborts this one; `[]` past 5 s or when the provider throws. Additive. |
| `completions.submit` | `{ cwd: string, text: string, cursor: number }` | `void` | Runs the providers' submit hooks (`resolveReferences`, `onSubmit`) on a prompt about to be sent; resolves after them, or after 5 s. Additive. |

### interactions

| Method | Params | Result | Description |
| - | - | - | - |
| `interactions.list` | `{ sessionId?: string }` | `PendingInteraction[]` | Lists pending interactions (questions and prompts the agent asked the user), optionally for one session. |
| `interactions.respond` | `{ requestId: string, response: InteractionResponse }` | `{ ok: boolean }` | Answers a pending interaction. |
| `interactions.cancel` | `{ requestId: string, reason?: string }` | `{ ok: boolean }` | Cancels a pending interaction. `reason` is passed back to the agent. |
| `interactions.rehydrate` | `{ sessionId: string }` | `PendingInteraction[]` | Re-sends the session's pending interactions, for a client that connected after they were raised. |
| `interactions.rehydrateSessions` | `{ sessionIds: string[] }` | `PendingInteraction[]` | `interactions.rehydrate` for each of `sessionIds` not running on this host, loading only those whose run waits to be resumed or whose last turns hold an unanswered question or plan (a channel start, over every conversation it had). |

### browser

| Method | Params | Result | Description |
| - | - | - | - |
| `browser.registerHost` | `{ hostId: string }` | `{ ok: boolean }` | Dev browser: a window offers to host the pages the agent's `browser_*` tools drive (the GUI's main process). Released when the connection drops. It then answers each `browser:request` signal addressed to it. |
| `browser.unregisterHost` | `{ hostId: string }` | `{ ok: boolean }` | Withdraws a window as dev browser host. |
| `browser.respond` | `{ requestId: string, response: BrowserResponse }` | `{ ok: boolean }` | Answers a `browser:request` signal with the host's result. |

### terminal

| Method | Params | Result | Description |
| - | - | - | - |
| `terminal.state` | `{ sessionId: string }` | `TerminalSessionState` | Terminals, run by the host in the session's environment. `clientId` names one view (a window's drawer or panel); views attached through a connection are detached when it drops. Output arrives as `terminal:output` signals; `ack` what was written to keep it flowing. |
| `terminal.open` | `TerminalOpenInput` | `TerminalInfo` | Opens a terminal in a session's environment. |
| `terminal.attach` | `{ terminalId: string, clientId: string, maxLines?: number }` | `TerminalAttachResult` | `maxLines` caps the history snapshot to the last lines a view can show (0 or absent: no cap). |
| `terminal.detach` | `{ terminalId: string, clientId: string }` | `void` | Detaches a view from a terminal. |
| `terminal.write` | `{ terminalId: string, data: string, clientId?: string, response?: boolean }` | `void` | `clientId` + `response`: bytes the view's emulator generated (DSR/DA replies), dropped unless that view owns the size. |
| `terminal.resize` | `{ terminalId: string, clientId: string, cols: number, rows: number, claim?: boolean }` | `void` | Resizes the terminal to a view's size. `claim` makes this view own the size. |
| `terminal.ack` | `{ terminalId: string, clientId: string, offset: number }` | `void` | Acknowledges terminal output up to `offset`, so the host keeps it flowing. |
| `terminal.restart` | `{ terminalId: string }` | `TerminalInfo` | Restarts a terminal's process and resolves with its info. |
| `terminal.close` | `{ terminalId: string }` | `void` | Closes a terminal. |
| `terminal.setHidden` | `{ terminalId: string, hidden: boolean }` | `void` | Hides a terminal from the agent, or shows it again. |
| `terminal.registerViewer` | `{ viewerId: string }` | `{ ok: boolean }` | This connection can show terminals (the desktop app). While at least one viewer is attached, agents get the terminal tools; the registration is dropped with the connection. |
| `terminal.unregisterViewer` | `{ viewerId: string }` | `{ ok: boolean }` | Withdraws a viewer registered with `terminal.registerViewer`. |
| `terminal.projectState` | `TerminalProjectStateInput` | `TerminalProjectState` | Terminals without a session: a shell on the host's machine in a project's folder, or in a new-chat draft's (`draftId`). Agents never see them. `terminal:project-changed` carries a scope's full list. |
| `terminal.openInProject` | `TerminalProjectOpenInput` | `TerminalInfo` | Opens a terminal bound to a project rather than a session. |
| `terminal.adopt` | `{ draftId: string, sessionId: string }` | `TerminalAdoptResult` | The draft became `sessionId`: its terminals move there in one pass (over the cap: back to the project). |
| `terminal.releaseDraft` | `{ draftId: string }` | `void` | The draft was discarded (or became a server session): its terminals go back to the project. |

### approvals

| Method | Params | Result | Description |
| - | - | - | - |
| `approvals.resolve` | `{ requestId: string, decision: ApprovalDecision }` | `void` | Answers a pending approval request with a decision. |
| `approvals.denyAll` | `{ sessionId?: string }` | `void` | Denies every pending approval, or only those of `sessionId` when given. |
| `approvals.listPending` | `Record<string, never>` | `WireApprovalRequest[]` | Lists the approval requests waiting for an answer. |

### safelist

The "Always allow" entries approvals saved, per project. A `chat` token can revoke one.

| Method | Params | Result | Description |
| - | - | - | - |
| `safelist.list` | `Record<string, never>` | `{ projects: SafelistProject[] }` | The host's "Always allow" entries, per project (its `projects.json`): what approving with `accept-safelist` added. Additive. |
| `safelist.remove` | `{ project: string, entry: string }` | `{ removed: boolean }` | Revoke one "Always allow" entry of a project; `removed: false` when it was not there. Additive. |

### queue

| Method | Params | Result | Description |
| - | - | - | - |
| `queue.list` | `{ sessionId: string }` | `QueuedItem[]` | Type-ahead queue, host-owned so every window sees and can manipulate the same list. `queue.add` queues when a run is in flight and sends immediately otherwise; the host auto-drains the queue FIFO at run end. |
| `queue.add` | `{ sessionId: string, text: string, activatedSkills?: string[], attachments?: PromptAttachment[], asCommand?: string, keepDraft?: boolean, origin?: SendOptions['origin'], literal?: boolean, clientMessageId?: string, metadata?: SendOptions['metadata'], delivery?: QueueDelivery } & SendRequestId` | `{ delivered: 'queued' \| 'sent' \| 'steer', consumed?: 'handled' \| 'steer' \| 'followUp' }` | Sends a message: right away when the session is idle; while a run works, into the run or into the queue (`delivery`). `delivered` says which: `sent`, `steer` (the run took it, as a steer or a follow-up) or `queued`. The message goes through the `prompt` interceptors first, as `chat.send`; `consumed` says one took it (`delivered` is then `sent` for `handled`, `steer` otherwise). |
| `queue.remove` | `{ sessionId: string, id: string }` | `boolean` | Removes a queued entry by id. Resolves `true` when it was removed. |
| `queue.update` | `{ sessionId: string, id: string, text: string, attachments?: PromptAttachment[] }` | `boolean` | Edit a queued entry in place; order is kept. `attachments` replaces the entry's list when given. `false` when already sent / dropped. |
| `queue.attachments` | `{ sessionId: string, id: string }` | `PromptAttachment[] \| null` | A queued entry's attachments with their bytes; `null` when already sent / dropped. |
| `queue.pushNow` | `{ sessionId: string, id: string }` | `{ delivered: 'steer' \| 'send' \| 'queued' \| 'missing' }` | Steer a queued entry, attachments included, into the live run now (falls back to send when idle). |

### drafts

| Method | Params | Result | Description |
| - | - | - | - |
| `drafts.get` | `{ sessionId: string }` | `{ text: string, shell?: boolean, attachments?: PromptAttachment[], search?: { active: boolean, query: string } }` | Current shared draft for a session; backfill on window attach. |
| `drafts.set` | `{ sessionId: string, text: string, shell?: boolean }` | `void` | Replace the session's shared draft. Fans out `draft:changed` to every OTHER peer. Ephemeral daemon state; drafts don't survive a daemon restart and are cleared when any window submits the prompt. `shell` carries the prompt's shell mode OUT OF BAND; never encoded into the text, so a literal leading `!` in a normal draft can't flip other windows into shell-execution semantics. |
| `drafts.setAttachments` | `{ sessionId: string, attachments: PromptAttachment[] }` | `void` | Replace the shared draft's composing attachments (notification). |

### search

| Method | Params | Result | Description |
| - | - | - | - |
| `search.set` | `{ sessionId: string, active: boolean, query: string }` | `void` | Replace the shared transcript-search state (notification, per keystroke). |

### extensions

What a folder's extensions declare in Settings: their options and action rows (`extensions.actions`), the actions' status probes (`extensions.actionStatuses`) and a run (`extensions.runAction`: the token needs the action's own `scope`, `admin` unless the extension lowers it; the config patch it returns is the client's to save with `settings.set`), and a new extension's scaffold (`admin`, behind `allowRemoteInstalls`).

| Method | Params | Result | Description |
| - | - | - | - |
| `extensions.configSchema` | `{ cwd: string }` | `WireExtensionConfigSchema` | The options the enabled extensions declare for `cwd` (`registerConfigItem`), without the action items or the callbacks; the values live on the settings' `extensionConfig`. Additive. |
| `extensions.actions` | `{ cwd: string }` | `ExtensionActionSummary[]` | The action rows (`registerConfigItem` type `'action'`) the enabled extensions declare for `cwd`. Additive. |
| `extensions.actionStatuses` | `{ cwd: string }` | `Record<string, ExtensionConfigActionStatus>` | The actions' passive `status()` probes, keyed `extension:key`; a probe that throws or takes over 3 s is left out. Additive. |
| `extensions.runAction` | `{ cwd: string, extension: string, key: string }` | `ExtensionActionRunResult` | Runs an action on the host. The config patch it returns comes back sanitized, not written: the client saves it (`settings.set`). One run of an action at a time. A token needs the action's `scope` (the action row's `scope`; default, and for config-declared actions, `admin`). Additive. |
| `extensions.create` | `CreateExtensionInput` | `CreateExtensionResult` | Scaffolds a new extension and reloads the registries: in the host user's `extensions` folder (`scope: 'user'`, the default), or in a project (`scope: 'project'`: `cwd`'s checkout, which must lie in the workspace roots; it loads once the project is trusted). Sessions stay in the roots: one may open in a project extension's folder, never in a user extension's. It writes code the host will run: a zidane-server's `allowRemoteInstalls` gates it, every attempt is audited. Every window gets `catalog:changed`. Additive. |
| `extensions.ui.list` | `{ cwd: string }` | `WireUiList` | Every UI item of `cwd`'s extensions, per token, in wire form, and each active extension's compiled `ui` bundle (`bundles`, by extension id). `extensions:ui` pushes each change after it. Additive. |
| `extensions.entries` | `{ cwd: string }` | `WireExtensionEntry[]` | The extensions of `cwd`: status, folder, `tui` entry and compiled `ui` bundle. What a window draws extensions from in daemon mode, without running their `setup`. `extensions:entries` says when to list again. Additive. |
| `extensions.ui.invoke` | `{ cwd: string } & UiInvokeRequest` | `unknown` | Runs one callback of a UI item by its id (a panel's `load` / `onAction`, a reference `resolve`, a badge menu or action, a settings page action, status or check). Callbacks run on the host under their extension's guard. Additive. |
| `extensions.links.open` | `LinkOpenParams` | `{ handled: boolean }` | Offers a link the user opened to `cwd`'s extensions' link handlers (`LinkHandler`), higher `priority` first; a handler that returns `false`, throws or times out passes it on. `handled: false`: open it the app's own way. Apps call it for every link they open, except with Alt held. Additive. |
| `extensions.storage.load` | `{ cwd: string } & WireStorageLocation` | `StorageSnapshot & { projectRoot?: string }` | Everything stored at a location of `cwd`'s extension storage, and the project root its `project`/`session` files live under. Additive. |
| `extensions.storage.set` | `{ cwd: string, area: StorageArea, key: string, value?: Json } & WireStorageLocation` | `WireStorageSetResult` | Writes (or, `value` absent, deletes) one storage entry. The extensions holding it see the change at once; every other connection gets `extensions:storage`. A `stores` write must be a `{ v, value }` record of the store's version whose value passes its schema (checked against every runtime of the folder holding the store); otherwise it is refused and nothing changes. The result says which, with the entry as the host holds it now, for the client to reconcile. Additive (the result was `void`). |
| `extensions.settings` | `{ cwd: string, sessionId?: string }` | `WireExtensionSettings` | The extensions' settings of `cwd` with values, locks and the layer each value comes from. `sessionId` adds that session's layer. Additive. |
| `extensions.settings.set` | `SettingsSetParams` | `void` | Writes one extension setting at a scope: `user` in the settings file, `project` in the project's `extension-settings.json`, `session` in memory; a secret goes to the credentials file, never a settings file. Refused for a locked, unknown or action key, a scope the setting doesn't allow, or a value that fails its checks. Additive. |
| `extensions.commands` | `{ cwd: string, sessionId?: string }` | `WireExtensionCommands` | The extension commands of `cwd` with their effective bindings, scopes and modes, and the keymap mode on top for `sessionId`. Additive. |
| `extensions.command.run` | `CommandRunParams` | `boolean` | Runs a command by its published id (an extension command or a built-in). `false` when nothing ran. Additive. |
| `extensions.keymap.push` | `{ cwd: string, mode: string, priority?: number, sessionId?: string }` | `string` | Activates a keymap mode on the host for `cwd` (as `ctx.keymap.mode.push`) until `extensions.keymap.pop` or this connection closes. Returns its handle. Additive. |
| `extensions.keymap.pop` | `{ handle: string }` | `boolean` | Pops a mode this connection pushed. Additive. |
| `extensions.keymap.list` | `{ cwd: string, sessionId?: string }` | `WireKeymap` | Every command of `cwd` with its effective bindings, scope and mode (`ctx.keymap.list`), and the active mode stack for `sessionId` (`ctx.keymap.active`). Additive. |
| `extensions.integrations.list` | `{ cwd: string }` | `WireIntegration[]` | The extension integrations of `cwd` (`ctx.integrations.register`) and their connections. Additive. |
| `extensions.integrations.connectKey` | `{ cwd: string, integrationId: string, key: string, methodId?: string }` | `WireIntegration` | Connects with an API key: verified by the extension, then kept in the credentials file. Additive. |
| `extensions.integrations.start` | `{ cwd: string, integrationId: string, methodId: string, flowId?: string }` | `WireIntegration` | Runs an OAuth or command method. Its steps (open this URL, enter this code, paste the redirect) come to this connection as `extensions:integration-flow` with the returned `flowId`; the call resolves when the flow ends. Additive. |
| `extensions.integrations.flowInput` | `{ flowId: string, inputId: string, value?: string }` | `boolean` | Answers a flow step that asked for input (`inputId`). `value` absent cancels the flow. Additive. |
| `extensions.integrations.disconnect` | `{ cwd: string, integrationId: string, account?: string }` | `void` | Removes the connection of `account`, or every connection of the integration when it is absent. Additive. |
| `extensions.integrations.setDefault` | `{ cwd: string, integrationId: string, account: string }` | `void` | Makes the connection of `account` the integration's default. Additive. |
| `extensions.catalogs.provenance` | `{ cwd: string, kind: CatalogKind }` | `CatalogItemProvenance[]` | Which catalog items of `cwd` extensions added, changed or removed, with the extension that did it (Settings shows it). Additive. |
| `extensions.browse` | `{ source: string }` | `ExtensionSourceListing` | The extensions of a source (`owner/repo[#ref][:dir]`, a GitHub URL, an npm package, or a folder on the host), read from their manifests by the host with its GitHub login; nothing runs. Never fails for an unreadable source: the listing carries `error` (the default source `Tahul/zidane` then lists the native catalog, `builtin: true`). Each row's `install` is what `catalog.install` takes (`spec`, `extension`). Additive. |
| `extensions.changeOrigin` | `{ name: string, scope: 'user' \| 'project', source: string }` | `{ name: string, origin: ExtensionInstallOrigin, message: string, progress: string[] }` | Install an installed extension again from another source (GitHub or npm): the source must hold an extension of the same `name` (else the `name-mismatch` install error, its `data` an `ExtensionInstallErrorData` with the names found); the folder is replaced only once the new copy validated, and `.install.json` records the new source, which `catalog.update` then follows. A zidane-server takes `scope: 'user'` only, gated and audited as `catalog.install`. Additive. |

### rpc

Methods of the RPC contracts that extensions register, called by protocol clients. A client's token must reach the method's `scope` (default `chat`). Events arrive on the `rpc.event` signal. [RPC and contracts](https://zidane.si/docs/extensions-rpc.md) explains the contracts.

| Method | Params | Result | Description |
| - | - | - | - |
| `rpc.call` | `RpcCallParams & { cwd?: string }` | `RpcResponseEnvelope` | Calls an extension's RPC method as this connection. Never throws for a failed call: a declared error, invalid input, a missing provider or a method whose `scope` this connection's token does not reach (`rpc.forbidden`; a local connection is not limited) comes back as `ok: false` with its `type`. `directory` (or `cwd`) picks the runtime; without it, the newest runtime that has the contract. `callId` lets `rpc.cancel` abort it. Also served as `POST /rpc/<contract>/<method>` on a zidane-server, with the same token. Additive. |
| `rpc.cancel` | `{ callId: string }` | `boolean` | Aborts this connection's `rpc.call` of `callId`; it answers `rpc.aborted`. `false` when none is in flight. Additive. |

### ui

Dialogs and UI requests from extensions, routed to the client that views the session. A client answers a dialog with `ui.dialog.answer` and reports what it views through `presence`. [Placements](https://zidane.si/docs/extensions-placements.md) explains the routing.

| Method | Params | Result | Description |
| - | - | - | - |
| `ui.client.announce` | `UiClientAnnounce` | `void` | What this connection shows and whether it shows dialogs: dialogs (`ui:dialog`) and UI requests (`ui:client`) go to the client viewing the session, else the last active one. Only announced clients get them. Send again on focus or session change. Additive. |
| `ui.client.reply` | `{ requestId: string, result: unknown }` | `boolean` | Answers a `ui:client` request sent to this connection. `false` when it is no longer pending. Additive. |
| `ui.dialog.answer` | `{ dialogId: string, answer?: unknown }` | `boolean` | Answers a `ui:dialog` shown on this connection (`answer` absent: cancelled). `false` when it is no longer pending. Additive. |
| `ui.dialog.validate` | `{ dialogId: string, key: string, value: unknown }` | `string \| null` | Checks one value of a `form` dialog shown on this connection: the field's error message, or `null` when it is valid (or the dialog is no longer pending here). Clients call it as the user edits a field and before they answer, and show the message under the field. The host checks the answer again: an answer with an invalid value makes the extension's `ctx.ui.form` reject, so a client must not answer until every field validates. Additive. |

### references

The extensions' text references in prose: the patterns, then the views of the keys a client found.

| Method | Params | Result | Description |
| - | - | - | - |
| `references.providers` | `{ cwd: string, peek?: boolean }` | `ReferenceProviderWire[] \| null` | The extension text reference providers (`ctx.registerReferences`) of `cwd`: the patterns a client matches prose with. `peek`: only from a registry already built for `cwd` (`null` when none is), so a list of titles across projects never builds one per project. Additive. |
| `references.resolve` | `{ cwd: string, items: { providerId: string, key: string }[] }` | `(ExtensionReferenceView \| null)[]` | The views of the keys a client found, in order: `null` for one that names nothing. At most 50 keys per call. Additive. |

### badges

| Method | Params | Result | Description |
| - | - | - | - |
| `badges.actions` | `{ cwd: string, target: BadgeTarget }` | `BadgeMenuItem[]` | The extensions' items (`ctx.registerBadgeActions`) of a badge's menu in `cwd`; the built-in ones are the client's. Additive. |
| `badges.run` | `{ cwd: string, source: string, id: string, target: BadgeTarget }` | `BadgeActionEffect` | Runs an extension's badge item; the effect (copy, open, a notice) is the client's to apply. Additive. |

### panels

A folder's extension side panels, run on the server. A failing panel answers its error as content, never as a JSON-RPC error. `panels.watch` is counted per connection: a client calls it when it shows a panel and `panels.unwatch` when it hides it, and a dropped connection releases its own; `panel:changed` goes to the watching connections only.

| Method | Params | Result | Description |
| - | - | - | - |
| `panels.list` | `{ cwd: string }` | `PanelSummary[]` | The side panels `cwd`'s extensions register. A panel's `load` and `onAction` run on the host, bound to `cwd` and the session `ctx` names, with a hard timeout: a failing panel answers an empty content naming why (`load`) or `ok: false` (`action`), never an error. Additive. |
| `panels.load` | `{ cwd: string, panelId: string, ctx?: PanelLoadContextInput }` | `PanelContent` | One panel's content for `ctx` (the session it shows beside, what opened it). Additive. |
| `panels.action` | `{ cwd: string, panelId: string, actionId: string, payload?: unknown, ctx?: PanelLoadContextInput }` | `{ ok: true } \| { ok: false, error: string }` | Run one of a panel's actions (a row's or the footer's `actionId`, its `payload`). Additive. |
| `panels.watch` | `{ cwd: string, panelId: string }` | `void` | Keep a panel's live updates on: this connection gets `panel:changed` when its content changes. Calls count; a dropped connection releases its own. Additive. |
| `panels.unwatch` | `{ cwd: string, panelId: string }` | `void` | Release one `panels.watch` of this connection. |

### palette

A folder's extension command palette items, run on the server, watched like panels: `palette.watch` while the palette is open, `palette.unwatch` once it closes, `palette:changed` in between.

| Method | Params | Result | Description |
| - | - | - | - |
| `palette.list` | `{ cwd: string, ctx: PaletteContext }` | `PaletteItem[]` | The command palette items `cwd`'s extensions contribute (each item's `source` is its provider), and running one. A provider that fails lists one disabled row naming why; a failed run answers `ok: false`. Additive. |
| `palette.run` | `{ cwd: string, source: string, itemId: string, ctx: PaletteContext }` | `{ ok: true, result: PaletteRunResult } \| { ok: false, error: string }` | Run `itemId` on its provider (`source`); a nested list, a prompt or a draft comes back as `result`. Additive. |
| `palette.watch` | `{ cwd: string }` | `void` | As `panels.watch`, for every provider of `cwd`: `palette:changed`. Additive. |
| `palette.unwatch` | `{ cwd: string }` | `void` | Release one `palette.watch` of this connection. |

### suggest

| Method | Params | Result | Description |
| - | - | - | - |
| `suggest.resolve` | `{ sessionId: string, text: string, caret: number, seq: number }` | `{ suggestion: string \| null, seq: number, candidates?: string[] }` | Resolve inline ghost-text autocomplete daemon-side (the typing window's request IS the single computation): canonical resolver against the session cwd's extension registry. Latest-wins per session; a `seq` older than the newest seen returns `{ suggestion: null }` without computing, and a newer request aborts an in-flight compute. The result additionally fans out as `suggest:changed` to every OTHER peer. |

### settings

| Method | Params | Result | Description |
| - | - | - | - |
| `settings.get` | `Record<string, never>` | `HostSettings` | Reads the host settings. |
| `settings.set` | `{ patch: Partial<HostSettings> }` | `HostSettings` | Applies a partial settings patch and resolves with the full settings after it. |

### prefs

The picks a client remembers across apps, in the server's `state.json`.

| Method | Params | Result | Description |
| - | - | - | - |
| `prefs.get` | `Record<string, never>` | `HostComposerPrefs \| null` | The picks remembered across apps (`state.json`): effort and options per model, the next chat's model chain. `null` when none is. Additive. |
| `prefs.set` | `{ patch: HostComposerPrefs }` | `void` | Merges picks into `state.json` (each top-level key replaces). Additive. |

### sidebar

The sidebar's views per space and the space on show, in the server's `state.json`: a web app finds them again in any browser. A client reads them (`sidebar.getViews`, `sidebar.getSpace`) and saves one when it changes (`sidebar.setView`, `sidebar.setSpace`); every client of the server shares them. The kit's view record also carries `session`, the id of the conversation last open in that space, which a client reopens when it switches back to the space; the server stores the record as given.

| Method | Params | Result | Description |
| - | - | - | - |
| `sidebar.getViews` | `Record<string, never>` | `Record<string, unknown>` | The sidebar views a client saved on this host, by space id (`state.json`'s `sidebarViews`): each client checks what it reads. Additive. |
| `sidebar.setView` | `{ spaceId: string, view: Record<string, unknown> }` | `void` | Saves one space's sidebar view (at most 4 KiB of JSON), stored as given: the kit's record is the filter axes plus `session`, the conversation last open in that space. Additive. |
| `sidebar.getSpace` | `Record<string, never>` | `string \| null` | The space a client's sidebar showed last, `null` when none was saved. Additive. |
| `sidebar.setSpace` | `{ spaceId: string }` | `void` | Saves the space the sidebar shows. Additive. |

### presence

| Method | Params | Result | Description |
| - | - | - | - |
| `presence.list` | `Record<string, never>` | `SessionPresence[]` | Current presence snapshot; backfill for a freshly opened session list. |
| `presence.watch` | `{ sessionId: string }` | `void` | Tag the calling peer as watching a session (one per connection; the TUI opens one connection per activated session). Sent as a notification right after the handshake. Detach is implicit; the daemon recomputes presence when the socket drops. |

### integrations

Clients that run beside the host, such as the [channels](https://zidane.si/docs/channels.md) runner, register their state here. Every window gets `integrations:changed` when the list changes.

| Method | Params | Result | Description |
| - | - | - | - |
| `integrations.register` | `{ name: string, kind: string, state: IntegrationState, detail?: string }` | `void` | Report an integration this client runs (a channel). Registering a name again from the same connection replaces it; a name another live connection registered is refused. It goes away when this connection closes. Every window gets `integrations:changed`. Additive. |
| `integrations.update` | `{ name: string, state: IntegrationState, detail?: string }` | `void` | Change the state of an integration this connection registered; `detail` replaces the previous one (absent clears it). Additive. |
| `integrations.list` | `Record<string, never>` | `IntegrationInfo[]` | The integrations registered on this host, in registration order. Additive. |

### host

What the server's machine can do: its OS, the tools on its PATH, the sandbox, the environment kinds and what it allows. The answer is cached for a minute; `refresh: true` probes again.

| Method | Params | Result | Description |
| - | - | - | - |
| `host.capabilities` | `{ refresh?: boolean }` | `HostCapabilities` | What this host can do: OS, tools on PATH, sandbox, environment kinds, what it allows. Cached a minute; `refresh` probes again. |
| `host.installTool` | `{ name: string }` | `{ ok: boolean, output: string, capabilities: HostCapabilities }` | Install a missing tool on this host, as the host's user, when it can without root (`toolInstall` says `run`). Then probes again: `capabilities` is the new answer. |

### server

The server's self-update: its state, the setting and the newest release seen. Any token reads it (`server.update.status`); turning it on or off, checking now and installing now need `admin`. With it off, `server.update.check` downloads and verifies a newer release and reports `state: 'ready'`; `server.update.install` installs it.

| Method | Params | Result | Description |
| - | - | - | - |
| `server.update.status` | `Record<string, never>` | `ServerUpdateStatus` | A zidane-server's self-update: its state, the setting, the newest release seen. |
| `server.update.configure` | `{ enabled: boolean }` | `ServerUpdateStatus` | Turn the zidane-server's self-update on or off. |
| `server.update.check` | `Record<string, never>` | `ServerUpdateStatus` | Check for a newer zidane-server release now. With auto-update on, a newer one installs; off, it is downloaded and verified, and the state turns `ready`. |
| `server.update.install` | `Record<string, never>` | `ServerUpdateStatus` | Install the release that is `ready` or `waiting` now, without waiting for background work to finish. |

### fs

The folders of the server's workspace roots, for a folder picker: list them, search them by name (bounded), and create one (`allowCreateFolders`). Each takes a `chat` token. A path outside the roots is refused.

| Method | Params | Result | Description |
| - | - | - | - |
| `fs.roots` | `Record<string, never>` | `FsRoots` | Where this host lets a session's folder be: a server's workspace roots (`confined`: `sessions.create` / `setFolder` refuse anything outside), or the user's home on a local host (unconfined). Additive. |
| `fs.listDirs` | `{ path?: string }` | `FsDirListing` | The folders in `path` (default: the first root), for a folder picker on the host's machine. Only inside the roots (real paths: `..` and symlinks out are refused), no dotfiles. Additive. |
| `fs.mkdir` | `{ parent: string, name: string }` | `FsDirEntry` | Creates folder `name` in `parent` (inside the roots, real paths: `..` and symlinks out are refused). `name` is one path segment and does not start with a dot. Refused when the host turns it off (a zidane-server's `allowCreateFolders`, the hello's `createFolders`). Additive. |
| `fs.searchDirs` | `{ query: string, path?: string }` | `FsDirSearch` | The folders whose name holds `query` (case-insensitive), in `path` and below it (default: every root). Bounded: four levels down, 200 results, no dot folders, no `node_modules`, symlinks not followed. `truncated`: the result or read bound cut the search short. Additive. |

### git

A folder's git checkout, inside the server's workspace roots. The writes `git.checkout`, `git.createBranch` and `git.sync` need an `admin` token. `git.clone` needs a `chat` token, and the server refuses it while `allowClone` is off. A clone's progress comes as `git:clone:progress` signals to the connection that started it. A connection that calls `git.watch` gets `git:refs` for that folder once its refs move (a commit, a fetch, a branch switch, from any process on the server), until it calls `git.unwatch` or drops. `git.commit` reads one commit's facts, for a transcript's SHA chip.

| Method | Params | Result | Description |
| - | - | - | - |
| `git.status` | `{ cwd: string }` | `GitInfo \| null` | Where the checkout stands (branch, upstream, ahead / behind, uncommitted changes); `null` outside a checkout. |
| `git.branches` | `{ cwd: string }` | `GitBranchRow[]` | The local branches, most recently committed first, each against the default branch. |
| `git.pullRequests` | `{ cwd: string, search?: string, fresh?: boolean }` | `GitPrListResult` | Open pull requests (`gh`): waiting on you, yours, then recent others; `search` searches every open one. `null` without `gh` (or signed out); `rateLimited` while GitHub refuses and nothing is cached. |
| `git.branchPullRequest` | `{ cwd: string, branch: string, fresh?: boolean }` | `GitPr \| null` | `branch`'s open (or draft) pull request, or `null`. `fresh`: past the cache. |
| `git.commit` | `{ cwd: string, sha: string }` | `GitCommitInfo \| null` | One commit's facts, for a sha chip; `null` when the repository has no such commit. |
| `git.fileStatus` | `{ cwd: string }` | `GitStatusEntry[]` | Each changed path's status against HEAD, untracked files included. |
| `git.diff` | `{ cwd: string, path?: string, context?: number \| null }` | `string` | The working tree against HEAD as a unified patch (untracked files as new files), the whole checkout or one `path`, with `context` lines (absent: 3; `null`: whole files). |
| `git.checkout` | `{ cwd: string, target: GitSwitchTarget, sessionId?: string }` | `GitCheckoutResult` | Switches the checkout to a branch or a pull request (`{ pr }`, through `gh`). Never forced; refused while an agent runs in the checkout (`sessionId`: the asking session, counted too). A branch another worktree holds is git's refusal plus that worktree and its sessions (`sessionId` left out). |
| `git.createBranch` | `{ cwd: string, name: string, sessionId?: string }` | `GitCheckoutResult` | Starts branch `name` from the current checkout and switches to it; refused while an agent runs in it. |
| `git.sync` | `{ cwd: string, sessionId?: string }` | `GitSyncResult` | Pulls (merging) then pushes the current branch, or publishes it without an upstream; refused while an agent runs in it. |
| `git.clone` | `{ input: string, parent: string }` | `RepoCloneResult` | Clones `input` (a git or forge URL, or `owner/repo`) into a new folder of `parent` (inside the roots). A folder already holding that repository is answered as `existed`. One clone per connection; git's progress comes as `git:clone:progress` signals to the caller only. A dropped connection cancels it. Refused when the host turns cloning off (a zidane-server's `allowClone`, the hello's `clone`). |
| `git.cloneCancel` | `Record<string, never>` | `void` | Cancels this connection's clone: the half-cloned folder is removed and `git.clone` answers `cancelled`. |
| `git.watch` | `{ cwd: string }` | `boolean` | Follows `cwd`'s checkout: once its refs move (a commit, a fetch, a push, a branch switch, from this host or any other process), this connection gets `git:refs` for `cwd`. `false`: not a repository, nothing watched. Calls count: each `git.watch` takes one `git.unwatch`; a dropped connection releases its own. Additive. |
| `git.unwatch` | `{ cwd: string }` | `void` | Releases one `git.watch` of `cwd` by this connection. |

### files

A folder's files beyond a session's workspace: search in files, what names in prose point at (`files.resolvePaths`, for a transcript's path chips), the project's own diagnostics (`files.diagnostics` runs project tools: `admin`), and its media. Diagnostics are cached until the files move; `files.diagnosticsInvalidate` drops a folder's cache so the next run checks again, and `files.diagnosticsTools` lists the tools and whether each runs there, for a settings list.

An `<img>` or `<video>` can't send a token, so media go over HTTP under a ticket: the client picks a random one (at least 32 URL-safe characters) and registers it with `files.mediaTicket` on each connection; it is good while that connection is open. `GET /zidane/media?ticket=…&cwd=…&path=…` serves a file of a folder in the workspace roots, `&side=old` or `&side=new` one side of its git change (the repository must lie in the roots too). Only image, video and audio types are served, with byte ranges, at most 64 MiB per response (a longer range is cut short, a larger whole file refused with 413), never sniffed into another type (`X-Content-Type-Options: nosniff`) and sandboxed if opened as a document. A missing or dropped ticket is 401, anything outside the roots 404.

| Method | Params | Result | Description |
| - | - | - | - |
| `files.search` | `{ cwd: string, query: string, caseSensitive?: boolean, searchId?: string }` | `ContentSearchResult` | Lines containing `query` (fixed string, at most 500 characters) under `cwd`: ripgrep, else `git grep`, else a walk. A later search with the same `searchId` on the connection cancels this one. |
| `files.resolvePaths` | `{ cwd: string, texts: string[] }` | `ResolvedPath[]` | What names in prose point at (files, folders, else branches), in order: what a transcript's path chips open. |
| `files.list` | `{ cwd: string }` | `FileEntry[]` | The files under `cwd` and the folders holding them, as `@` completes them: git's list (ignore rules kept), else a walk; capped. |
| `files.location` | `{ cwd: string }` | `FolderLocation` | Where `cwd` sits: its git checkout (the folder itself outside git) and its project (the main checkout for a worktree). |
| `files.diagnostics` | `{ cwd: string, paths: string[], disabled?: string[], timeoutMs?: number }` | `DiagnosticsResult` | Runs the project's own linters and typecheckers on `paths` (relative to `cwd`), cached until the files move. `disabled`: tool ids switched off; `timeoutMs`: per tool. |
| `files.diagnosticsInvalidate` | `{ cwd: string }` | `void` | Drops the cached diagnostics of `cwd`: the next run checks again. |
| `files.diagnosticsTools` | `{ cwd: string }` | `DiagnosticsToolInfo[]` | The diagnostics tools and whether each runs for `cwd` (configured, missing its binary, not used). |
| `files.mediaTicket` | `{ ticket: string }` | `void` | Lets this connection's media play from `GET /zidane/media` (`SERVER_MEDIA_PATH`): `?ticket&cwd&path` serves a file of a folder in the workspace roots, `&side=old\|new` one side of its git change, with byte ranges, for an image, a video or audio only. The client picks the ticket (at least 32 random URL-safe characters) so an address is known before the call lands; it is good while this connection is open and carries its token's scope. One per connection: a new one replaces it. Only a zidane-server serves media. Additive. |

### usage

The welcome screen's numbers over the server's session store, as the desktop app sums its own. Days are the server's local days.

| Method | Params | Result | Description |
| - | - | - | - |
| `usage.summary` | `{ range: UsageRange }` | `UsageSummary` | Token spend, models, streaks and activity over `range`, from the host's session store, as the desktop app's welcome screen shows its own (`summarizeUsage`). Days are the host's local days; `userName` is `null` (the host's user is not the reader). Refused by a store that keeps no usage history (not a SQLite file). Additive. |
| `usage.providers` | `{ refresh?: boolean }` | `readonly ProviderUsageSnapshot[]` | The plan and subscription windows of each provider the host holds a credential for (every account), probed with the host's credentials and cached a few minutes; `refresh` probes past the cache. A provider that can't be read reports `status: 'unavailable'`. Additive. |
| `usage.github` | `{ probe?: boolean }` | `ProviderUsageSnapshot[]` | GitHub's API limits as the host's GitHub client last saw them, per GitHub host; `probe` asks GitHub (`/rate_limit`, which costs no call). Additive. |

### auth

`auth.remove` is also a sign-out: it drops one stored credential, or all of them.

A browser can't listen on a loopback port for an OAuth redirect, so a browser client signs in to an MCP server through the server: it passes `redirectUri` as `<origin>/zidane/oauth/callback`, the origin it reaches the server by. The server registers that redirect, takes the code at that address itself, and answers `begin` with `mode: 'browser'`. The client opens `url` and calls `auth.oauth.complete` with no code; it resolves once the token exchange is done. A server whose pre-registered client pins its redirect stays `'loopback'`: the user pastes the address of the page that failed to load (`complete` with `input`). The code is matched to the flow by its `state`, which is random and single-use. An MCP server's stored client registered for another redirect registers again.

| Method | Params | Result | Description |
| - | - | - | - |
| `auth.status` | `Record<string, never>` | `{ providers: HostProviderAuth[] }` | The host's provider credentials, as the host's agents use them (its data dir's `credentials.json` and environment): per provider, whether something is configured, its stored API key / OAuth accounts (labels, which is active) and an env var it reads. Never a secret. Additive. |
| `auth.setApiKey` | `{ provider: string, key: string }` | `HostProviderAuth` | Store an API key for one of the host's providers as its active credential, apply it to the host's environment, and rebuild agents (idle now, busy ones at run end). Returns the provider's new status. OAuth sign-in goes through `auth.oauth.*`. Additive. |
| `auth.remove` | `{ provider: string, account?: string }` | `{ removed: boolean, provider: HostProviderAuth }` | Remove one stored credential (`account`: its key from `auth.status`) or, without one, every stored credential of the provider; then as `auth.setApiKey`. A key the host only has from its own environment is left alone. Additive. |
| `auth.configure` | `{ provider: string, value?: string, customFields?: Record<string, string> }` | `HostProviderAuth` | Stores a provider's API key and its custom fields (a local server's URL, a cloud's region; `onboarding.providers` lists them), merged into the stored credential: a field left out keeps its value, an empty one is removed (a blank secret keeps the stored one), a required one may not be emptied, no `value` keeps the stored key. Additive. |
| `auth.setPreferred` | `{ provider: string, target: 'apikey' \| 'oauth' \| { key: string } }` | `HostProviderAuth` | Make one of a provider's stored credentials the active one (`target`: an account key from `auth.status`, or the first stored one of a source), then as `auth.setApiKey`. A credential no longer stored is refused. Additive. |
| `auth.rename` | `{ provider: string, account: string, label: string }` | `HostProviderAuth` | Rename a stored OAuth account (`account`: its key from `auth.status`). Presentation only: nothing is rebuilt. Additive. |
| `auth.oauth.begin` | `{ provider?: string, account?: string, mcpServer?: string, cwd?: string, kind?: string, redirectUri?: string }` | `HostOAuthBegin` | OAuth sign-in with the browser on the CLIENT and the tokens on the host: the host starts a provider's login (`provider`) or an MCP server's (`mcpServer`, found in `cwd`'s catalog, as `catalog.mcps`), keeping the PKCE verifier and `state`, and answers with the authorization URL. `mode: 'loopback'`: the client listens at `redirectUri` (a provider's fixed port; for an MCP server the loopback URI the client passed) and forwards the code; `'browser'`: the client opens `url` (pasting a code if the page shows one); `'device'`: shows `userCode`. The flow belongs to this connection, is single-use and expires (`expiresAt`). Additive. A client that can't listen (a browser) signs in to an MCP server through a zidane-server's own redirect: `redirectUri` is the server's `/zidane/oauth/callback` (`SERVER_OAUTH_CALLBACK_PATH`) at the origin the client reaches it by. The server takes the code there and the flow is `'browser'` (the client opens `url`, then `complete`s without a code). A server whose pre-registered client pins its redirect stays `'loopback'`: its page fails to load and the user pastes that page's address (`complete` with `input`). `account` (a provider's only): the stable id of a stored OAuth account (`AuthFailure.accountId`) this sign-in re-authenticates. Its tokens replace that account's in place (same slot, id and name), instead of becoming a new active account. An id the host does not store is refused. Additive. |
| `auth.oauth.complete` | `{ flowId: string, code?: string, state?: string, input?: string }` | `HostOAuthComplete` | Deliver the code (`code` + `state` from the loopback, or `input`: what the user pasted) once, then wait for the host's token exchange; without either, just wait (device and polling flows). A wrong `state` or another connection's flow is refused. Tokens stay on the host: the result says what changed, never a secret. |
| `auth.oauth.respond` | `{ flowId: string, stepId: string, value?: string }` | `HostOAuthBegin` | Answer the step a flow is waiting on (`HostOAuthBegin.step` before its URL, `HostOAuthComplete.step` after): a select's option id (none: cancel), a prompt's text. Answers with where the flow is now: its URL, or the next step. Then `complete` as before. Additive. |
| `auth.oauth.cancel` | `{ flowId: string }` | `{ cancelled: boolean }` | Abandon a flow of this connection. |

### catalog

| Method | Params | Result | Description |
| - | - | - | - |
| `catalog.skills` | `{ cwd?: string, kind?: string }` | `{ skills: HostCatalogSkill[] }` | What the host discovered for the settings tabs, each entry with whether the host's settings enable it (an unset allowlist enables everything). `cwd` adds the project scope of that folder (inside the workspace roots on a server); without it, the user scope (the host user's home). The enabled lists are written with `settings.set`. Installing, removing and MCP sign-in stay on the host's machine. Additive. |
| `catalog.mcps` | `{ cwd?: string, kind?: string }` | `{ servers: HostCatalogMcp[], errors: HostCatalogError[] }` | MCP servers: `env` and `headers` values, URL credentials and query values are redacted. |
| `catalog.extensions` | `{ cwd?: string, kind?: string }` | `{ extensions: HostCatalogExtension[] }` | Extensions installed for `cwd`. |
| `catalog.plugins` | `{ cwd?: string, kind?: string }` | `{ plugins: HostCatalogPlugin[] }` | Plugins installed for `cwd`, with what each contributes. |
| `catalog.install` | `{ kind: HostInstallKind, spec: string, scope?: 'user', skills?: string[], all?: boolean, extension?: string, overwrite?: boolean }` | `{ installed: string[], message: string, progress: string[] }` | Install a skill or an extension on the host, in its user's scope only (`scope` may only be `'user'`): a GitHub (`owner/repo[#ref][:dir]`, a GitHub URL, a skills.sh URL) or npm spec; paths are refused. `skills` / `all` pick from a multi-skill source (the `multiple-candidates` error lists them), `extension` from a multi-extension one (an extension's `multiple-candidates` or `no-extension` error carries the names found as `ExtensionInstallErrorData`). Runs the host's package managers and fetches code there: a zidane-server's `allowRemoteInstalls` gates it, every attempt is audited. Bounded in time; the progress lines come back with the result. Every window gets `catalog:changed`. Additive (`remoteInstalls` on `ipc.hello`). |
| `catalog.remove` | `{ kind: HostInstallKind, name: string }` | `{ removed: string, message: string }` | Remove a user-scope skill or extension of the host by name (its directory). As `catalog.install`. |
| `catalog.update` | `{ kind: HostInstallKind, name: string }` | `{ updated: string, upToDate: boolean, message: string }` | Update a user-scope extension installed from a source. As `catalog.install`. |

### skills

| Method | Params | Result | Description |
| - | - | - | - |
| `skills.browse` | `{ source: string }` | `SkillSourceListing` | The skills of a source (`owner/repo[#ref][:dir]`, a GitHub or skills.sh URL), fetched by the host with its GitHub login; `catalog.install` with `skills` installs picked ones. Additive. |

### onboarding

What the setup wizard finds on the server's machine and the providers it lists, with their custom fields (stored with `auth.configure`).

| Method | Params | Result | Description |
| - | - | - | - |
| `onboarding.detect` | `{ cwd?: string }` | `OnboardingDetection` | What the setup wizard finds on the host's machine for `cwd` (default: the host user's home): the providers and their credentials, other agents to import from, local model servers and the install signals. Additive. |
| `onboarding.providers` | `{ cwd?: string }` | `HostOnboardingProvider[]` | The providers as the wizard's Connect step lists them, with their custom fields and the model each runs when none is picked. Additive. |
| `onboarding.complete` | `{ settings: OnboardingSettingsChange, records: OnboardingState }` | `HostSettings` | The setup wizard's write: `settings.set` sets (`settings.reset` back to their defaults, unless also set), as `settings.set` applies them, and `records` (the run finished, its steps) merged into the host's stored ones. Returns the settings in effect. Additive. |

### claudeImport

A Claude Code setup in the server user's `~/.claude`: `claudeImport.detect` says whether there is one, `claudeImport.preview` what each piece would copy and skip, and `claudeImport.run` imports it. The import copies commands the server will run: `admin`, behind `allowRemoteInstalls`.

| Method | Params | Result | Description |
| - | - | - | - |
| `claudeImport.detect` | `{ cwd?: string }` | `ClaudeImportDetection` | What `~/.claude` on the host holds: whether there is a setup, and its transcripts for `cwd` and in every project. Additive. |
| `claudeImport.preview` | `{ cwd?: string }` | `OnboardingImportPreview` | What each piece of the Claude Code import would copy and skip. Additive. |
| `claudeImport.run` | `{ cwd?: string } & ClaudeImportSelection` | `ClaudeImportSummary` | Imports a Claude Code setup into the host's own places: conversations into its session store, MCP servers, skills, commands and subagents into the host user's files. Idempotent (what is there is skipped, never overwritten). It copies commands the host will run: a zidane-server's `allowRemoteInstalls` gates it, every attempt is audited. Every window gets `catalog:changed`. Additive. |

### mcp

`catalog.mcps` rows carry each server's `status`: its sign-in state, its tools as last listed and its last Test. A `stdio` row always says where it runs (`scope`: `host` or `context`), and `shared` comes from its config. An absent `shared` means shared, and the `mcpSharing` setting wins over it. `mcp.upsert` writes the server user's `mcps.json`, or with `scope: 'project'` the project of `cwd`. An Advanced OAuth client secret goes in `clientSecret`. The server keeps it in its MCP credential store, never in `mcps.json`.

| Method | Params | Result | Description |
| - | - | - | - |
| `mcp.upsert` | `{ config: McpServerConfig, scope?: 'user' \| 'project', cwd?: string, clientSecret?: string }` | `{ name: string, created: boolean }` | Add or replace an MCP server in the host user's `mcps.json`, or with `scope: 'project'` in `cwd`'s project (a folder inside the workspace roots on a server). Its `env` / `headers` values are stored there and never come back (`catalog.mcps` redacts them, the reply names the server only). `clientSecret`, the secret of its `oauth` client, goes to the host's MCP credential store, never to `mcps.json`; an entry without an `oauth` client drops a stored one. As `catalog.install`. |
| `mcp.remove` | `{ name: string }` | `{ removed: boolean }` | Remove an MCP server from the host user's `mcps.json`; `removed: false` when it has none. As `catalog.install`. |
| `mcp.test` | `{ name: string, cwd?: string, kind?: string }` | `McpServerStatus` | Connect to one of `cwd`'s MCP servers (as `catalog.mcps`), list its tools into the host's tools cache and record how it went: the server's new `status`. Every window gets `catalog:changed`. Additive. |
| `mcp.signOut` | `{ name: string }` | `void` | Forget an MCP server's sign-in on the host (its tokens). Every window gets `catalog:changed`. Additive. |
| `mcp.instances` | `{ cwd?: string }` | `McpServerInstance[]` | The host's shared MCP server processes (`src/mcp/server-pool.ts`), `cwd`'s only when given: name, folder, shared or per session, how many sessions hold it, its pid, whether it is retired. Remote servers and `scope: 'context'` ones are never listed (they are per session). Additive. |
| `mcp.restart` | `{ name: string, cwd?: string }` | `{ restarted: number }` | End `name`'s process now (in `cwd` only, when given) for every session holding it. Calls in flight fail with "<name> restarted" and are not retried; the next call starts a fresh process. Answers how many processes were restarted (0: none was running). Additive. |

### models

| Method | Params | Result | Description |
| - | - | - | - |
| `models.list` | `{ cwd?: string, kind?: string }` | `{ providers: HostModelProvider[] }` | The host's credentialed providers (what its agents can run) and their models, for a model picker of a session on that host. `cwd` adds the providers its extensions contribute there. Additive. |
| `models.test` | `{ cwd?: string, kind?: string, model: string }` | `HostModelTestResult` | Send one tiny prompt with this host's credentials, to check a chat model answers. `model` is `"providerKey[@account]/modelId"`, tested exactly. Never fails: a broken model comes back as `ok: false` with a reason. Additive. |

### decisions

| Method | Params | Result | Description |
| - | - | - | - |
| `decisions.test` | `{ cwd?: string, kind?: string, model?: string }` | `HostDecisionTestResult` | Send one tiny decision with this host's credentials, to check a decision model works. `model` is `"providerKey/modelId"`, tested exactly; omitted = the host's `Settings.decisionModel` (or the first available). Never fails: a broken model comes back as `ok: false` with a reason. Additive. |

### machines

| Method | Params | Result | Description |
| - | - | - | - |
| `machines.list` | `Record<string, never>` | `MachineInfo[]` | The machines this host knows (`docs/MACHINES.md`), online or not, and forgetting one (it disconnects; its `local@` / `worktree@` kinds stop resolving). Additive. |
| `machines.forget` | `{ machineId: string }` | `{ forgotten: boolean }` | Forgets a machine. It disconnects and its kinds stop resolving. |
| `machines.listDirs` | `{ machineId: string, path?: string }` | `{ path: string, dirs: string[], roots: string[] }` | Folders of a machine, for a client's folder picker: the host proxies a listing to the machine (`fs.list`, confined to its roots there): the folders in `path` (absent: its first root), and every root. Additive. |
| `machines.share.start` | `{ hostId: string, name: string, roots: string[], token?: string }` | `MachineLinksStatus` | A local host's machine links (`docs/MACHINES.md` § Client UI), behind remote hosts: share this computer with a saved server (the machine agent runs in this host's process, with the machine token `token` or the one already stored for that server), stop sharing, use a saved server as a machine (reverse direction), and read their state. Refused while remote hosts are off. Additive. |
| `machines.share.stop` | `{ hostId: string }` | `MachineLinksStatus` | Stops sharing this computer with a server. |
| `machines.useServer` | `{ hostId: string, enabled: boolean }` | `MachineLinksStatus` | Turns on or off using a server as this computer's linked server. |
| `machines.links.status` | `Record<string, never>` | `MachineLinksStatus` | State of this host's machine links: the shares it offers and the servers it uses. |

### machine

| Method | Params | Result | Description |
| - | - | - | - |
| `machine.attach` | `{ hostId: string }` | `MachineRegisterParams` | Reverse direction (`docs/MACHINES.md` § Reverse direction): a client asks this server to lend itself as a machine over this very connection (`allowAsMachine` on, else refused). `hostId` names the asking host, so a reconnect finds its tasks and terminals again. Then `machine.call` carries each host→machine request (`MachineMethods`), answered by the server's confined executor; machine notifications come back as `machine:notification` signals on this connection only. |
| `machine.call` | `{ method: string, params?: JsonValue }` | `JsonValue` | Runs one machine-protocol method on this server's executor and resolves with its JSON result. Refused when the server does not lend itself as a machine. |
| `machine.detach` | `Record<string, never>` | `{ detached: boolean }` | Ends this connection's machine attachment. `detached` is `false` when it had none. |

### tokens

| Method | Params | Result | Description |
| - | - | - | - |
| `tokens.createMachineToken` | `{ name: string }` | `{ id: string, name: string, token: string }` | Mint a `machine`-scoped token on this server (a zidane-server only), so a window can share its computer in one step. An admin token may (`./scopes`). Audited. |
| `tokens.list` | `Record<string, never>` | `ServerTokenInfo[]` | This server's tokens, revoked ones included, never their secrets (a zidane-server only). Additive. |
| `tokens.create` | `{ name: string, scope: ServerTokenScope }` | `ServerTokenInfo & { token: string }` | Create a token with a scope (`./scopes`; `machine` lends a computer). The secret is in the result only: show it once. Bound to the calling token (`createdBy`), so revoking that one revokes this one too. Audited. Additive. |
| `tokens.revoke` | `{ id: string }` | `{ revoked: ServerTokenInfo[] }` | Revoke a live token by id, with the live tokens it minted; their connections drop and the runs and terminals they started stop. Audited. Additive. |
| `tokens.revokeSelf` | `Record<string, never>` | `{ revoked: ServerTokenInfo[] }` | Revoke the token this connection authenticated with, and the live tokens it minted (a browser's sign out). Any client scope may: it only gives up its own access. Audited. Additive. |
| `tokens.pair` | `{ scope: ClientScope, ttlMs?: number, name?: string }` | `{ code: string, expiresAt: number }` | Issue a one-time pairing code (`XXXX-XXXX`, a zidane-server only): a browser posts it to `POST /zidane/pair` and gets a token of `scope`, named `browser: <name>` and bound to the calling token (`createdBy`). `ttlMs`: 30000 to 3600000, default 600000. Single use; five wrong secrets burn it. Audited. Additive. |
| `tokens.machineInvite` | `{ name?: string, ttlMs?: number }` | `{ code: string, expiresAt: number }` | Issue a one-time machine join code (a zidane-server only): a machine posts it to `POST /zidane/pair` and gets a machine token named `name`. A public server caps `ttlMs` at 10 minutes. Audited as `pair.create` with scope `machine`. Additive. |

### logs

A zidane-server's recent log records: its own lines and its extensions' (setup, runtime warnings), from a bounded in-memory buffer. Admin only. `logs.watch` follows the log (`logs:record` for each new record) until the client calls `logs.unwatch` or drops; calls count per connection.

| Method | Params | Result | Description |
| - | - | - | - |
| `logs.list` | `Record<string, never>` | `{ records: LogRecord[] }` | A zidane-server's recent log records, oldest first: its own lines and its extensions' (setup, runtime warnings), from a bounded in-memory buffer. Other hosts refuse it. Additive. |
| `logs.watch` | `Record<string, never>` | `void` | Follow the log: this connection gets `logs:record` for each new record. Calls count; a dropped connection releases its own. Additive. |
| `logs.unwatch` | `Record<string, never>` | `void` | Release one `logs.watch` of this connection. |

### perf

The host's own performance, for a client's Settings › Performance on it: the host process as the root of its tree, what its sessions launched, its event loop and, on macOS, the GPU. Admin only. `perf.watch` sends `perf:sample` every 2 s until the client calls `perf.unwatch` or drops; calls count per connection, and the host samples only while a connection watches. `perf.threads` answers only for a process the host lists.

| Method | Params | Result | Description |
| - | - | - | - |
| `perf.watch` | `Record<string, never>` | `void` | Follow the host's own performance (Settings › Performance on a server): this connection gets `perf:sample` every 2 s. The host samples only while a connection watches. Calls count; a dropped connection releases its own. Additive. |
| `perf.unwatch` | `Record<string, never>` | `void` | Release one `perf.watch` of this connection. Additive. |
| `perf.history` | `Record<string, never>` | `{ samples: PerfSample[] }` | The samples the host holds (the last 5 minutes of watching), oldest first. Additive. |
| `perf.threads` | `{ pid: number }` | `{ threads: PerfOsThread[] }` | The OS threads of one of the processes the host lists (its own tree), busiest first. Refuses any other pid. Additive. |

### sync

| Method | Params | Result | Description |
| - | - | - | - |
| `sync.status` | `Record<string, never>` | `SyncStatus` | Settings sync, a client's computer → this server (`docs/SERVER.md` § Settings sync). A zidane-server only; other hosts refuse. One source device per server: `sync.claim` makes the caller it (or says who is, unless `takeover`), `sync.push` applies one category's snapshot through the host's own setters, skipping fields edited here since (overrides), `sync.reset` drops an override, `sync.release` stops the source. Every call is audited with its token. Additive. |
| `sync.claim` | `{ deviceId: string, deviceName: string, takeover?: boolean, categories?: SyncCategory[], keys?: string[] }` | `SyncClaimResult` | Makes the caller the settings source device. When another device is the source, the result says which, unless `takeover` is set. `categories` and `keys` narrow what it syncs. |
| `sync.push` | `{ deviceId: string, category: SyncCategory, payload: SyncPayload }` | `SyncPushResult` | Applies one category's snapshot from the source device. |
| `sync.reset` | `{ field: string }` | `{ reset: boolean }` | Drops the local override of one field so sync applies to it again. |
| `sync.release` | `{ deviceId: string }` | `{ released: boolean }` | Stops the source device's sync. |

### daemon

| Method | Params | Result | Description |
| - | - | - | - |
| `daemon.info` | `Record<string, never>` | `HelloResult` | Daemon identity and version: `protocolVersion`, `appVersion`, `pid`, `startedAt` and the host identity fields of the `hello` result. |
| `daemon.ownedProcesses` | `Record<string, never>` | `OwnedProcess[]` | The processes the daemon started for its own work (MCP servers, bounded tool runs such as git and linters), which lead their own process group like a session's command does. The desktop app's performance monitor counts them as the app's. Additive: an older daemon answers "method not found", read as none. |
| `daemon.sessionProcesses` | `Record<string, never>` | `SessionProcess[]` | The processes the daemon started for a session (its shell tool's commands and background tasks, its terminals, its own MCP servers), by session id. The desktop app's performance monitor shows what each session uses. Additive: an older daemon answers "method not found", read as none. |
| `daemon.shutdown` | `{ whenIdle?: boolean }` | `{ deferred?: boolean } \| void` | Graceful drain: deny pending approvals, abort live runs, close the socket. Used by clients that detect a protocol-version mismatch and need the stale daemon gone before respawning. `whenIdle` (version-skew retirement): a daemon with active work does NOT abort it. It answers `{ deferred: true }` and exits once idle. Explicit stops (`zidane daemon stop`) omit it and drain immediately. Daemons predating the flag ignore it and drain immediately. |
