Using Zana on multiple machines
There are two separate ways to use another computer with Zana:
- Enrolled machines run a host daemon. The other box outbound-connects to this app. Add a folder on that machine from Settings → Machines (or the host picker when adding a local project). Threads then execute there.
- SSH remotes are a project folder on a host from
~/.ssh/config. Threads run on a host daemon installed on that box (Add remote or composer Install). Composer Send waits until the daemon is bound and online. The env chip showsuser@host · path · Online. This machine's host daemon must be connected (it owns~/.sshfor the install).
Copy-paste join remains for boxes you cannot SSH to from this machine.
Zana does not ship BB Connect, a separate tunnel product, or a mobile bridge. Pairing another computer always goes through this app's enrolled host daemon and the existing website relay.
Reachability (public origin)#
The product server still binds loopback (127.0.0.1). Another computer
cannot enroll against http://127.0.0.1:<port>. Official desktop builds carry
the public Heroku origin and relay token (inlined at electron-vite build from
ZCC_APP_URL and ZCC_RELAY_TOKEN). This laptop dials
wss://<origin>/_zcc/relay — Heroku never inbound-connects to the laptop.
Precedence: runtime env, then the values baked into that build, then
Settings → Machines → Public app URL, then the repo public-app-url
file. pnpm dev seeds ZCC_APP_URL from that file when the env is unset.
Do not commit the token; set the same ZCC_RELAY_TOKEN on Heroku and in
the release/CI environment (GitHub secrets ZCC_APP_URL / ZCC_RELAY_TOKEN,
or put them in gitignored .env for local pnpm dev). The public dual-arch
build is produced by pushing a vx.y.z tag.
The token authenticates a laptop to open a session. Isolation between laptops
is the session URL (/t/<sessionId>), not a personal key. Join/enroll through
that id stays open while this laptop’s relay is connected. The join hint
renews in the background so Install / Fix never hit a closed window. Host
websockets keep working until this app quits. If the laptop tunnel drops,
join/enroll return relay_offline until Zana reconnects.
Join commands use https://<origin>/t/<sessionId> so remotes route to the
right laptop.
If the origin is baked/set but the tunnel is down, Install fails with
relay_offline (keep Zana running). Dev builds without those env vars stay
loopback; use SSH reverse-tunnel pairing.
That origin is used in the join command and as the Host-header allowlist
on the laptop (enroll, host websocket, /install.sh).
The Heroku dyno has a separate path allowlist (website/relay/allowlist.json,
mirrored in the product server): /install.sh, /install/version,
/install/zcc-host.tgz, enroll, interactive-request (and interrupt), and
/internal/hosts/ws. Other product HTTP — including /internal/hosts/tool-call
— is not relayed.
Operator detail for the front door (ZCC_RELAY_TOKEN, node relay/front-door.mjs)
is in `website/README.md`.
Do not bind the product server to 0.0.0.0 or use Tailscale Funnel. Those
would put an unauthenticated control plane on a network.
Opening the full Zana UI in a browser through that URL is out of scope — the desktop app on this machine stays the control surface. SSH reverse-tunnel copy-paste (below) remains the offline fallback.
Add an execution machine#
- Open Settings → Machines and choose Add a machine.
- Copy the one-line installer. It looks like:
curl -fL ${publicAppUrl}/install.sh | sh -s -- \
--join-code <zcde_...> --host-id <id> --server ${publicAppUrl}- Run it on the computer that should execute work. The join code expires in 15 minutes and can be redeemed once. The Machines list turns the new row online when the daemon's websocket is open.
If the machine is an SSH host (for example limited-pony) and you have
not set a public app URL, Add machine copies a laptop-side command instead:
ssh -o ExitOnForwardFailure=yes -R 18782:127.0.0.1:<zcc-port> limited-pony \
'curl -fL … http://127.0.0.1:18782/install.sh | sh -s -- --join-code … --host-id … --server http://127.0.0.1:18782'Paste that in a terminal on this computer. It reverse-tunnels product HTTP
to the remote host and runs the installer there. Leave the SSH session open so
the daemon can keep that tunnel. The installer looks for Node 22+ on PATH,
then nix / nvm / fnm / volta (a Node 20 PATH entry is skipped). Override with
ZCC_NODE=/path/to/node.
The installer requires Node.js 22 or newer on the remote box. Manual
pairing downloads the host-daemon tarball from /install/zcc-host.tgz. Composer
Fix (enrolled machines that are offline) pipes that same tarball over SSH
from this machine instead of asking the remote to curl it.
Each joined server gets its own daemon instance and data directory
(~/.zcc-machines/<server-host>). Joining never touches a full local install's
~/.zcc. Subsequent runs reuse the reserved local API port under
~/.zcc-machines/host-daemon-ports/; pass --host-daemon-port <port> to
override.
On macOS the installer loads a LaunchAgent. On Linux it enables a systemd user
unit when that bus is available; Salesforce workspaces and other boxes without
user systemd keep the daemon running in the background instead of hanging.
Both start the daemon with --auto-update.
Fix from the composer#
When an already-paired machine is offline, the composer shows Fix. If Zana
stored an SSH alias for that host, Fix restarts the LaunchAgent or systemd user
unit and reinstalls if restart does not reconnect. If no SSH alias is stored,
Fix asks you to pick a host from ~/.ssh/config, then retries.
Fix needs a public origin (baked into the official app, ZCC_APP_URL,
Settings, or the repo public-app-url file), not loopback. This machine's
host daemon must be connected — it owns ~/.ssh and performs the SSH. If SSH
cannot run, copy the Settings → Add machine join command.
Add remote project registers the SSH project and installs a host daemon over SSH. Composer Install stays available until a daemon is bound. Send is blocked until that daemon is online. If SSH cannot complete the install, retry from the composer or copy the reverse-tunnel command.
After it connects#
- New project — pick the machine in the host picker and browse its disk (or paste a path on that box).
- New thread / home composer — pick the machine when more than one host is connected, or when an enrolled machine is offline (Online/Offline in the picker). A project remembers the host it was created on.
- Provider CLIs — each machine row lists Codex / Claude (and other) CLI install state. Use Update all when any enrolled box is missing or outdated.
- Permission ceiling — Settings → Machines can cap that box at accept-edits or auto. Owner-session only; a thread cannot exceed the ceiling.
Machine names are labels and may be duplicated; the host id is the stable handle. The laptop that runs the product server is the primary machine and cannot be removed from the list.
Local Docker trial#
A Linux box lives in docker/remote-machine. It is
the same enroll path as a real remote: Node 22, /home/zcc/workspace, SSH on
port 2222, no systemd user bus (the installer nohups).
Start it and leave it idle:
pnpm docker:remote-machineThen either paste the Settings → Add a machine command inside the box:
docker exec -it zcc-docker bash
# or: ssh -p 2222 zcc@127.0.0.1 (password: zcc)
zcc-join --join-code <zcde_...> --host-id <id> --server <url>Or mint and enroll in one step (pnpm dev must be running). If the laptop
relay is connected, this uses https://<origin>/t/<sessionId>; otherwise it
publishes a loopback proxy so Docker can reach 127.0.0.1:
pnpm docker:host-daemonForce a door with --relay or --local. Prove a Linux box can enroll through
the session join URL with pnpm test:docker:pairing (needs Docker). Stop with
pnpm docker:remote-machine down.
Settings → Machines should show hostname zcc-docker. Add a project at
/home/zcc/workspace (sample app is in the repo under
docker/remote-machine/workspace). This is a pairing trial, not Tailscale Serve.
Self-update#
If session open reports a newer server protocol, the daemon downloads the server artifact, updates its private install, then exits so launchd/systemd restarts it. Failed attempts fall back to reconnect with a persisted backoff from 5 seconds to 5 minutes. Retry update in Settings → Machines bypasses the current backoff. A daemon never downgrades itself to an older protocol.
To opt out, remove --auto-update from the LaunchAgent plist or systemd unit,
then reload the service.
Where to go next#
- Getting started — first project and first agent.
- Using Zana — Inbox, Agents, and the day-to-day surfaces.