# Server clients

> Each zidane-server client (desktop app, TUI, browser, Node client, machine), how it attaches and what it can do.

Every client that connects to a server speaks the same [protocol](https://zidane.si/docs/server-protocol.md). The table compares what each client can do.

| Client | Token sent as | Approvals | Server settings | Settings sync | Machines | Reconnects |
| - | - | - | - | - | - | - |
| [Desktop app](#desktop-app) | Subprotocol | Answers them | Edits them | Yes | Yes | 1 s, doubling to 30 s |
| [TUI](#tui) | `Authorization` header | Answers them | Edits them | Yes | Yes | 1 s, doubling to 30 s |
| `zidane attach` | `Authorization` header | Answers them | No | No | No | No |
| [Browser](#browser) | Subprotocol | No | No | No | No | 0.5 s, doubling to 15 s; stops on a refusal |
| [Node client](#node-client) | Subprotocol under Node, header under Bun | Your code | Your code | Your code | Your code | Your code, or the saved-hosts helpers |
| [ACP](#acp) | Does not connect | | | | | |

## Turn remote hosts on

The Remote hosts setting turns on saved servers, the Hosts settings, per-host session groups and the host picker of a new session. It is off by default in both apps.

| App | Where |
| - | - |
| Terminal app | Settings › Agent › Sessions › Remote hosts |
| Desktop app | Settings › Advanced › Experimental › Remote hosts |

`--host <url>` (or `ZIDANE_HOST_URL`) connects one launch to one server, with or without the setting. `ZIDANE_NO_REMOTE=1` (or `zidane --no-remote`) wins over both. Remote hosts is a window setting and never syncs to a server ([setting kinds](https://zidane.si/docs/apps.md#settings)).

## Desktop app

Add servers under Settings › Accounts › Hosts. The main process of the app stays connected to every saved server. The window never talks to a server directly.

- The sidebar groups sessions by host. A new chat picks its host, then a folder on that host.
- A server session runs there, with the server's folder, files, commands and provider keys ([what lives where](https://zidane.si/docs/server.md#what-lives-where)).
- While a server session is open, Settings edits that server's agent settings, skills, MCP servers and extensions.
- A session is known by its host and id together.

Screens and steps: [Hosts and sync](https://zidane.si/docs/desktop-hosts.md).

## TUI

| What | Saved servers | One server for this launch |
| - | - | - |
| How | Turn remote hosts on, then Settings › Hosts › `+ add a server` | `zidane --host wss://… --token-file <file>`, or `ZIDANE_HOST_URL` and `ZIDANE_HOST_TOKEN` |
| Kept | `hosts.json` (0600), the token in `credentials.json` under `hosts:<id>` | In memory. The CLI removes both variables from its environment |
| Sessions | Grouped by host next to This computer | Every session on that server |

The footer shows `host: <name>`. Both apps read the same `hosts.json` and tokens. `zidane run --json` always runs locally and refuses `--host`.

`--token <t>` leaks into `ps` and your shell history. Use `--token-file` instead (`-` reads stdin).

`zidane attach` is a line client for one session. It watches the session, sends prompts and answers approvals ([more](https://zidane.si/docs/daemon.md#watch-from-another-terminal)).

```bash
zidane attach --host wss://zidane.example.com --token-file box.token
```

Screens and keys: [Remote hosts](https://zidane.si/docs/tui-remote.md).

## Browser

A web page attaches with `createServerHost` from `zidane-ui/adapters`, which backs the kit's chat components ([setup](https://zidane.si/docs/ui-adapters.md#zidane-server-over-websocket)). A page cannot answer tool approvals yet ([limits](https://zidane.si/docs/ui-adapters.md#limits)).

Underneath is `createServerClient` from [`zidane/ipc/browser`](https://zidane.si/docs/reference/ipc-browser.md): the same typed calls and signals as the Node client, in any runtime with a `WebSocket` (no Node APIs). It negotiates the protocol version, says what the token may call (`can`), queues calls while it reconnects, resumes from the last signal, and stops on a refusal no retry can fix: a bad or revoked token, an origin or transport the server refuses, a protocol the two don't share. A page can't read why an upgrade was refused, so after a failed connect it asks the server's auth check (`GET /zidane/auth`).

List the page's origin in `ZIDANE_SERVER_ALLOWED_ORIGINS` ([origins](https://zidane.si/docs/server-security.md#browser-origins)) and give the page its own token with the narrowest [scope](https://zidane.si/docs/server-security.md#scopes) it needs: any script in the page can read it. `examples/ui-server` runs a page against a scripted server.

## Node client

`connectWsClient` from [`zidane/ipc/remote`](https://zidane.si/docs/reference/ipc-remote.md) returns a client typed for every protocol method. It needs a global `WebSocket` (Node 22 or later, or Bun).

```ts
import { connectWsClient, HostAuthError } from 'zidane/ipc/remote'

try {
  const client = await connectWsClient({ url: 'wss://zidane.example.com', token: process.env.ZIDANE_HOST_TOKEN })
  const stop = client.onSignal((signal, meta) => console.log(signal.kind, meta?.seq))
  const session = await client.request('sessions.create', { cwd: '' })
  await client.request('chat.send', { sessionId: session.id, prompt: 'Run the tests.' })
  stop()
  client.close()
}
catch (err) {
  if (err instanceof HostAuthError)
    console.error(err.status, err.message) // 401: token refused; 403: err.reason says why
  else
    throw err
}
```

| Error | Means |
| - | - |
| `HostAuthError` | The upgrade got a 401 or a 403. `reason` carries the `X-Zidane-Refusal` code |
| `HostBusyError` | The upgrade got a 429: too many connections for the token or the address, or too many attempts. Transient |
| `IpcVersionMismatchError` (from `zidane/ipc`) | The protocol versions differ |

A redirect fails the connect. The client does not follow a redirect, so the token never goes to another address.

`keepHostConnected` and `startWsSavedHosts` add reconnection and the saved-hosts registry the apps use. A refused token or another host stops the retries until `retry()`. Another protocol version leaves the host `version-mismatch`, checked again every 5 minutes. Anything else backs off from 1 to 30 seconds. `retryAll()` reconnects every saved host now, as the desktop app does when the computer wakes. The client strips `$seq` from signals and passes it as `meta.seq`.

Under Bun, the client trusts the public certificate authorities and the operating system's store (`hostCaBundle()`), or the `ca` you pass. Under Node, call `trustSystemCertificates()` once to make every TLS client of the process trust the system store, as the desktop app does.

To embed the server in a Bun process, use `startZidaneServer` from `src/server/server.ts` (what `zidane-server start` runs). It is not a package export. `examples/ui-server/scripted-server.ts` imports it from source, with a token store, an origin and a workspace root.

## Machines

A machine lends a server its folders, shell, background tasks and terminals. It opens the WebSocket itself, so it needs no inbound port.

A machine gets no signals, session data, settings or keys. It can call only `$/ping`, `ipc.hello` and `machine.register`. The server sends it execution calls.

```bash
# On the server: a machine-scoped token.
zidane-server token create --name laptop --scope machine

# On the machine:
zidane machine share --host wss://zidane.example.com --token-file ~/.config/zidane/laptop.token \
  --name laptop --root ~/code
```

`zidane machine share` also reads the token from `ZIDANE_MACHINE_TOKEN`. Both apps' Hosts pages do both steps at once ("share this computer") through `tokens.createMachineToken`. The server gets a shell on the machine as your user. File operations stay inside the declared folders. Revoking the token drops the machine.

In the other direction, `--allow-as-machine` lets a server lend itself to its clients' computers. `zidane server setup` asks for it (default yes, never on a public server). It shows up there as `local@<server>`, and `ipc.hello` reports `machineServe: true`.

## Settings sync

Settings sync copies this computer's settings to one saved server, one way.

| Mode | Does |
| - | - |
| Off | Nothing (the default) |
| Import once | Copies a snapshot now |
| Keep in sync | Pushes each change as it happens. Changes made offline go on reconnect |

Categories, picked per server:

| Category | Copies |
| - | - |
| Agent settings | Tool approvals, run limits, compaction, interaction mode, subagent models. Never the window's own settings |
| Safe-mode allowlists | Per project, for projects the server also has (matched by path, then folder name). Others are skipped |
| `SYSTEM.md` and user skills | Your user-scope `SYSTEM.md` and skills, as files |
| MCP servers (user scope) | URL servers sync as they are. A stdio server is flagged when its command is missing on the server. Env and headers are copied too |
| Extensions | The server installs those that came from npm or GitHub, which needs `allowRemoteInstalls`. Local ones cannot sync |
| Provider API keys | Secrets, copied to another machine. Off by default. You opt in each key on its own |

A server takes settings from one device only. Its record in `<data dir>/sync.json` holds the device, its categories, the fields it set, and the fields edited on the server after that. Sync leaves those edits alone until you reset them. Your computer keeps its side in `hosts-sync.json`.

MCP servers and extensions arrive through the installs that `--no-remote-installs` turns off. MCP sign-ins never sync. Sign in on the server from its MCP servers tab, in either app. If another device already syncs to the server, the app names it and offers to take over.

Where the controls are: [desktop](https://zidane.si/docs/desktop-hosts.md#settings-sync), [terminal](https://zidane.si/docs/tui-remote.md).

## ACP

The [ACP adapter](https://zidane.si/docs/acp.md) runs the agent in a process your editor starts and never connects to a server. It shares only the JSON-RPC framing and standard error codes with the server's protocol.
