Guides
Cross-gateway peers
Let agents on separate machines list, peek at and message each other through one relay.
Every session already gets peers_list, peers_peek and peers_send for the other sessions on
its own gateway. A relay extends that across machines: run one on an always-on device, point
each gateway at it, and sessions on the other gateways show up in peers_list with ids of the
form gateway:session.
gateway A ──ws──┐
gateway B ──ws──┼──> relay (registry, auth, rules, routing)
gateway C ──ws──┘
Gateways only dial out, so only the relay has to be reachable (a tailnet, a LAN, or a reverse proxy).
1. Run the relay
workerdeck relay serve --host 0.0.0.0 --port 7777
# or the standalone package: npx @workerdeck/relay serve ...
TLS is optional: --tls-cert and --tls-key serve wss://. Without them the relay serves plain
ws:// and warns when bound off loopback, since each gateway’s key crosses the network in its
first frame. A tailnet or a TLS-terminating proxy covers that.
2. Enroll each gateway
workerdeck relay enroll mac-mini
This prints the gateway’s key once; the relay keeps only a hash. Save it on that gateway, for
example in ~/.workerdeck/relay.key with mode 0600. workerdeck relay revoke <name> removes a
gateway; a running relay disconnects it within seconds. workerdeck relay status lists who is
online.
3. Write the rules
The relay denies everything until a rule allows it. Rules live in ~/.workerdeck/relay/rules.json
and are reloaded on change:
{
"rules": [
{ "from": "mac-mini", "to": "pi" },
{ "from": "*", "to": "build-box", "allow": ["send"], "scope": { "projects": ["/srv/ci"] } }
]
}
fromandtoare gateway names or*.allowdefaults to["send", "peek"].scope.projectslimits a rule to sessions whose cwd or project root is under one of the paths.
4. Point each gateway at the relay
In the gateway’s workerdeck.config.mjs:
export default {
relay: {
url: 'ws://relay.tailnet.ts.net:7777',
gateway: 'mac-mini',
keyFile: '~/.workerdeck/relay.key',
expose: { allow: ['send', 'peek'] },
},
}
WORKERDECK_RELAY_KEY works in place of keyFile; the CLI removes it from the environment before
any agent or shell starts. expose is the gateway’s own ceiling, and the relay cannot widen it:
expose.scope limits which sessions are published at all (same shape as a session scope), and
expose.allow limits which operations this gateway accepts from others. caFile trusts a
self-signed relay certificate.
What the agents see
peers_list rows for remote sessions carry gateway, the full cwd, project, model, context
usage and status, plus allow, which says whether the caller may send to or peek at them. A
message from another gateway arrives framed as coming from that gateway, and the transcript shows
the sender as Name@gateway. The same guards apply as locally (16k characters, 10 messages a
minute per pair, a 12-hop chain that a human turn resets), enforced by both the relay and the
receiving gateway. If the relay is unreachable, peers_list still returns the local sessions and
says remote gateways are unavailable.
A session with a scope never reaches past its own gateway.
Teams across gateways
An agent can join a lead on another gateway: drag it onto the lead in the dashboard or VS Code
(both gateways must be configured in that client and online at the relay). The lead’s gateway
records an invitation, the member’s gateway joins under it, and the member then reaches its lead
and teammates wherever they run. Both gateways need team in expose.allow and a rule that
grants it:
{ "rules": [{ "from": "*", "to": "*", "allow": ["send", "peek", "team"] }] }
A gateway can waive the per-join invitation for agents of the same owner from gateways it trusts:
relay: { ..., teams: { acceptFrom: ['laptop', 'win-desktop'] } }.
The lead’s gateway is the authority on who is in its team; the member’s gateway decides whether its agent is in one. A gateway that drops off the relay keeps its teams restricted, never widened, and only an answer from the other gateway dissolves a membership.
Owners: sharing a relay with colleagues
Every gateway, session and agent has an owner: a label for whom it answers to, such as
tobias, ruli or an organisation. It is not a login and has nothing to do with model
credentials. Agents of different owners never see each other unless one of these holds:
- they are in the same team (lending an agent to a colleague’s team asks for confirmation), or
- both are shared top-level agents: each appears as a card in the other’s lists, and they can message each other, never peek. Members and plain sessions are never shared.
An agent is Private by default. Choose Private or Shared in New agent or the agent’s ⋯
menu; the gateway’s default is --agent-sharing private|shared (config agentSharing.default),
and --agent-sharing never shares nothing. An agent whose session runs without permission
prompts (bypassPermissions, dontAsk) receives nothing from other owners.
Enroll each gateway with its owner, and give a gateway several people use each owner it may claim:
workerdeck relay enroll laptop --owner tobias
workerdeck relay enroll mini --owners silkweave,tobias,ruli,dan
On a gateway with several owners, each profile names its owner in the config
(profiles: [{ name: 'ruli:claude', ..., owner: 'ruli' }]) and --owner <label> (config owner)
covers the rest. Such a gateway refuses to start a session that resolves to no owner. Rules
between gateways of different owners need "crossOperator": true; a rule without it covers
same-owner pairs only:
{ "from": "*", "to": "*", "allow": ["send", "team"], "crossOperator": true }
What this does not protect against:
- The relay is trusted. Gateways check each other’s claims, but a compromised relay can forge any of them. Run it on a tailnet or behind TLS, for people you trust.
- Owners separate agents, not people. Everyone who holds a gateway’s auth key can act for every owner on it, and a gateway enrolled with several owners can speak for all of them. Enroll the smallest set.
Versions
The relay and its gateways speak relay wire version 2 since WorkerDeck 4.0.0. Upgrade the relay
and every gateway on it to 4.0 together: 3.x and 4.0 cannot be mixed on one relay, and a
gateway on the other version is turned away with close code 4426 (the relay speaks a different wire version), logs it, and retries once a minute until the versions match.
The relay keeps its enrollments in gateways.json in its state dir, one entry per gateway name:
{ "hash": "<sha-256 of the key>", "enrolledAt": <epoch ms>, "owners": ["..."] }, where owners
is optional and absent means the relay’s default owner. An entry in any other shape, as written by
a relay before 4.0.0, makes the relay refuse to start and name the gateway; migrate the file once
before 4.0.0 starts on it. enroll, --owners and revoke write the new shape for you.
Running the relay as a service
The relay belongs on a machine that stays up, under a service manager that restarts it. The setup below is the recommended one: the relay listens on loopback only, and a TLS reverse proxy on the same machine is what the other gateways reach.
State
Give the relay a state dir of its own and pass the same --state-dir to every serve, enroll,
revoke and list (or set WORKERDECK_RELAY_STATE_DIR); a command run without it reads
~/.workerdeck/relay instead. rules.json goes in that directory.
mkdir -p /srv/relay && chmod 700 /srv/relay
launchd (macOS)
~/Library/LaunchAgents/dev.workerdeck.relay.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>dev.workerdeck.relay</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/node</string>
<string>/opt/homebrew/bin/workerdeck</string>
<string>relay</string>
<string>serve</string>
<string>--state-dir</string><string>/srv/relay</string>
<string>--host</string><string>127.0.0.1</string>
<string>--port</string><string>7777</string>
</array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardOutPath</key><string>/srv/relay/relay.log</string>
<key>StandardErrorPath</key><string>/srv/relay/relay.log</string>
</dict>
</plist>
launchd does not read your shell profile, so nothing is on PATH: name node and the
workerdeck script by absolute path (which node workerdeck). A Homebrew node keeps its path
across upgrades; an nvm or fnm path changes with every Node version and breaks the plist on the
next upgrade. Load it with launchctl bootstrap gui/$(id -u) <plist>, and after an edit run
launchctl kickstart -k gui/$(id -u)/dev.workerdeck.relay. A LaunchAgent runs as you and only
while you are logged in; a machine that must serve with nobody logged in needs a LaunchDaemon
with UserName. On Linux the same shape is a systemd unit with Restart=always.
TLS: a reverse proxy, or --tls-cert
A proxy that already holds a certificate it renews is the simpler option. It terminates TLS and forwards to the loopback relay; the WebSocket upgrade passes through untouched, and the relay accepts it on any path. With Caddy:
relay.example.com {
reverse_proxy 127.0.0.1:7777
}
Remote gateways then dial wss://relay.example.com; a publicly trusted certificate needs no
caFile. A gateway on the relay’s own machine dials ws://127.0.0.1:7777 and skips the proxy.
--tls-cert and --tls-key work too, but renewal and restart become your job.
The relay’s port carries nothing but the WebSocket. workerdeck relay status reads a unix socket
in the state dir (relay.sock, mode 0600), so it works the same behind a proxy or under
--tls-cert, and only a user who can read the state dir can ask.
Keys
enroll prints the key once. Send it straight to the gateway that needs it, without it landing
on a screen or in a chat:
workerdeck relay enroll laptop --state-dir /srv/relay | sed -n 's/^ //p' \
| ssh laptop 'mkdir -p ~/.workerdeck && umask 077 && cat > ~/.workerdeck/relay.key'
A lost key is enroll <name> --rotate, never a second name: the name is the routing prefix.
Verify
- On the relay’s machine,
workerdeck relay status(with the same--state-dirasserve) lists every enrolled gateway withonlineand a session count. - In a session on one gateway, ask the agent to call
peers_list: the other gateway’s sessions appear asgateway:session.peers_peekone andpeers_sendit a line; the reply comes back the same way. A parked session on the other gateway peeks aslive: falsewith no recent lines until something wakes it.
A gateway started with --hot-reload picks up a newly created config file with a relay block on
workerdeck reload, without dropping its sessions. Without --hot-reload, restart the gateway
when nothing is running.
Restarting the relay is harmless. Every gateway logs connection lost (1001 relay shutting down); reconnecting, comes back within seconds and republishes its sessions; KeepAlive does the
restart for you.