With remote access enabled the bridge additionally accepts ACP connections over WebSocket, so a phone or browser can watch and drive the same sessions as your editor. Zed (or any ACP editor over stdio) remains the primary client and owns the process: when the editor disconnects, the bridge — and every remote attachment — exits with it.
A ready-made client — Android APK plus a self-hostable web build — lives at william0wang/zcode-acp-remote.
phone / browser ──WS── tunnel ── hub (127.0.0.1:8377, single entry)
│ byte-level proxy
▼
bridge ACP endpoint (127.0.0.1:8378+n)
│ same AgentApp as stdio
Zed ──────── stdio ────────────────┘
Remote access is opt-in and requires a token (the endpoint is expected to sit behind a public tunnel, so "loopback-only" is never a safe assumption). The authoritative source is the global user config file — every bridge, the hub daemon and hub-spawned terminal windows read the same file, so behaviour does not depend on which process happened to spawn what:
~/.config/zcode-acp/config.json ($XDG_CONFIG_HOME aware):
{
"remote": {
"enabled": true,
"token": "<a-long-random-secret>",
"hubPort": 8377,
"hubHost": "127.0.0.1",
"bridgePort": 8378,
"terminal": { "app": "iTerm" }
}
}All fields are optional. Precedence per field: config file → environment
variable → built-in default. The env vars (ZCODE_ACP_REMOTE,
ZCODE_ACP_REMOTE_TOKEN, ZCODE_ACP_HUB_PORT, ZCODE_ACP_HUB_HOST,
ZCODE_ACP_REMOTE_PORT, ZCODE_ACP_HUB_TERMINAL*) keep their semantics and
fill any gap the file leaves, so existing env-only setups keep working.
Why a file: the hub is a detached daemon that idle-exits (~10 min) and is re-spawned by whichever bridge needs it next — its birth env rotates between GUI-launched editors (no shell vars) and interactive shells, so env-carried preferences drifted with every rebirth. Terminal preferences are re-read live at every remote incubation: edit the file, the next session-create uses it, no hub restart. Token/port changes apply when the hub is next (re)born — rotate while no hub is running, or kill it and let the next bridge re-spawn.
Alternatively, enable it per-agent in Zed's settings (Zed merges these into the agent's environment — no config file needed):
"agents": {
"ZCode": {
"command": "zcode-acp-server",
"env": {
"ZCODE_ACP_REMOTE": "1",
"ZCODE_ACP_REMOTE_TOKEN": "<a-long-random-secret>"
}
}
}Hub. The first bridge with remote enabled spawns the hub daemon as a
detached, machine-level singleton on ZCODE_ACP_HUB_PORT (it can also be run
manually). It does three things only: token auth, instance discovery, and
byte-level proxying (ACP WebSocket plus read-only session files) — no session
state, no path semantics. It exits after ~10 idle minutes and is re-spawned
on demand. Each bridge registers every 10s as a heartbeat and drops out of
discovery ~30s after it stops. Sandbox inheritance is self-healed: a hub
born inside the Seatbelt wrap (a sandboxed backend spawned it — marked via
ZCODE_ACP_SANDBOX_ACTIVE) relaunches itself through launchd, outside the
sandbox, before binding; inside the sandbox macOS would refuse its opening of
the visible session terminal (TCC), breaking remote session-create.
Discovery API (for client authors; fields are additive-only):
GET /api/instances → [{"id","port","pid","startedAt","workspace",
"origin","sessions":[{"sessionId","title?","updatedAt"}]}]
GET /api/instances?probe=1 → same list, minus bridges unreachable for ~8s
(one failed probe only marks them unhealthy)
WS /acp?instance=<id> → proxied to that bridge's endpoint
GET /api/instances/{id}/fs/… → read-only session files (list + raw bytes, ADR-0004)
origin labels how an instance was started: "editor" (a bridge an editor
spawned over stdio) or "serve" (a headless bridge created remotely, see
below).
Remote session-create (ADR-0014). A remote client can start a NEW agent session in any of the machine's known projects — no editor required:
GET /api/projects → [{"workspacePath","sessions","lastActive"}]
POST /api/instances {workspacePath} → {"id","reused"}
/api/projects aggregates the App's tasks index: every workspace that ever
ran a session (system temp trees, ~/.zcode itself, and vanished
directories filtered out), newest activity first. The list gates the POST —
paths outside it get 403 (a convenience bound, not a security boundary: a
token holder can already drive an editor-bridge session in any cwd; the
trust boundary is the token). On create the hub incubates a VISIBLE
interactive TUI window in the machine's terminal (ADR-0016 as amended by
ADR-0020, macOS; headless/SSH, remote.terminal.enabled: false in the config
file, or ZCODE_ACP_HUB_TERMINAL=0 falls back to the detached headless
zcode-acp serve); its bridge registers back within seconds (budget ~20s)
and is reachable like any other instance. The hub itself is a background
process with no terminal, so nothing is auto-detected: the target terminal
resolves as remote.terminal.command → remote.terminal.app from the config
file (re-read live at every incubation; the ZCODE_ACP_HUB_TERMINAL_COMMAND
→ ZCODE_ACP_HUB_TERMINAL_APP env vars fill in when the file is silent —
built-in launch recipes for Terminal, iTerm, WezTerm, kitty, Alacritty,
Ghostty; other names pass through to open -a) → plain Terminal.app. Warp
refuses .command files, so terminal.app: "warp" (or "warp-preview")
instead opens warp://action/new_tab?path=<script> — its URI scheme executes
the script as a new tab in Warp's default mode (app/src/uri/mod.rs →
open_file). Hyper stays unsupported (no programmatic command execution,
vercel/hyper#3677).
Create NEVER reuses a live serve-origin instance (ADR-0016 as amended) —
every create incubates its own window, and identical concurrent requests
join the same in-flight spawn. A
terminal-TUI instance lives while its window lives; the headless fallback
exits ~10 minutes after the last client detaches and the last turn
finishes. Session roots are pinned to the project cwd in both surfaces
regardless of what a client sends.
Remote session-resume (ADR-0015). Remote clients can also reopen a PREVIOUS conversation of a project — including closed ones no bridge currently advertises (discovery lists only running conversations):
GET /api/projects/sessions?workspacePath=… → {workspacePath, instance, sessions}
The listing is the project's full backend session store; the hub incubates
the project's serve bridge on first listing and reuses it after. Resume is
the normal attach flow with session/load on a listed backend id. See
docs/REMOTE-CLIENTS.md ("Resuming a closed session") for the client contract.
Remote project/session delete (ADR-0031). Both are soft deletes
(tombstones in the App's tasks-index, upstream deleteTask semantics — the
backend store keeps every conversation byte):
POST /api/projects/delete {workspacePath} → {ok, deletedTasks}
POST /api/instances/{id}/sessions/{sid}/delete → {ok, deleted}
A deleted project leaves GET /api/projects (and the session-create
whitelist); a deleted session leaves every listing (/api/projects/sessions,
ACP session/list, the CLI /resume picker, the desktop App sidebar).
Sessions archived in the desktop App hide the same way. Deleting never stops
anything: live conversations are refused (409), tombstoned resume ids get
404, and deletes are self-healing — opening the project anywhere (the CLI's
/resume picker, an editor tab) or using any session again cancels the hide;
a fully deleted project comes back whole the moment it is reopened.
See docs/REMOTE-CLIENTS.md ("Deleting a project or session").
sessions lists the project's currently running conversations (live
editor tabs and remote attachments) under the same ACP session ids the
editor uses — attaching by id joins the conversation's live notification
stream, and the hub dedupes sessions shared by several bridges of the same
project.
The local TUI used to be a hub client too (ADR-0018, removed with the
in-house REPL by ADR-0020). The Martty TUI boots its own bridge per window
and does not merge hub-live sessions into its picker; watching a session
driven from another client stays a remote-App capability (the hub's WS proxy
and the /api surface are unchanged).
Auth is Authorization: Bearer <token> or ?token= (browsers cannot set WS
headers); /api/* sends Access-Control-Allow-Origin: * — the token is the
security boundary. A proxied connection stays bound to one instance; switching
instances means reconnecting. Remote clients can also pull plan quota via the
non-standard account/usage_stats ACP method (no session required), and
browse/download the files of a session's project through the /fs routes
above. During replay, compaction summaries and rewritten tool calls arrive as
collapsed tool_call updates instead of walls of text
(REPLAY-GUIDE.md).
Building a remote client — web, mobile, or CLI? The full integration contract (endpoints, framing, lifecycle timings, failure recovery, platform notes) lives in REMOTE-CLIENTS.md.
Semantics. All agent notifications are broadcast to every client.
Permission / elicitation requests go to every client and the first answer
wins; losing clients receive $/cancel_request so their dialogs close.
Concurrent prompts for one session are serialized exactly as they are for a
single editor. Capabilities declared by any client are OR-merged.
Tunnels. Designed for one-port tunnels (Cloudflare Tunnel, frp): map the
hub port only. frp's tcp mode passes WebSocket as-is; Cloudflare Tunnel
drops idle WebSocket connections, so the hub sends 30s keepalive pings on both
legs. The bridge endpoint itself is loopback-only and never exposed.
Binding beyond loopback. The hub speaks plain HTTP/WS — the token travels
and authorizes in cleartext, so ZCODE_ACP_HUB_HOST=0.0.0.0 (needed only when
the tunnel agent runs in its own container) is exactly as safe as the network
it lands on. Keep the bind loopback unless that interface is private to the
tunnel agent, and put TLS in front before mapping it anywhere untrusted.