Specification: signalbox architecture

Where the processes are, what talks to what, and what secures it.
Note: this page is the living specification for signalbox's topology and security model. events.md owns the wire schema and the hub API; this page owns the deployment shape around it.

The shape of the system

Three roles, and every signalbox deployment is some arrangement of them:

Machine versus human is the axis the security model divides on. Surfaces write too, because seen, hide, label and tag are events like any other, so the divide is by actor rather than by direction. Credential type divides on the same axis: a hook cannot complete an interactive login.

Read plane and control plane

The read plane flows one way: events go agents to hub to surfaces, and a surface writes back only annotations about the display (seen, hide, label). Nothing a surface does touches a machine.

Jump adds a control plane: a request flows backwards through the bus and makes a machine act. A railway signal box is exactly this, a place you pull levers from to move things at a distance. The security model grows to match, and the rest of this page separates the two planes. Reading and actuating use separate credentials (see the security tiers).

Local: one machine built

The default, and the only shape that exists today. Everything is loopback.

Default deployment. No network, no auth, no configuration.
your mac
claudehook
opencodeplugin
piadapter
github actionssignalbox fire
POST /events ──▶ 200ms, spools
signalbox hub127.0.0.1:8377
events.jsonlthe only durable state
GET /state ──▶ GET /stream
menu bar appjumplist, supervises the hub
tmux picker
signalbox CLI

The app owns the hub

There is no LaunchAgent. The menu bar app spawns signalbox hub as a child, keeps it alive, and stops it on quit (Hub.swift). The bundle embeds a version-matched CLI at Contents/Resources/signalbox, so the app and the hub can never skew. Run the hub yourself with signalbox hub when you want it in the foreground.

Properties worth protecting

Security model: the process boundary

The hub binds 127.0.0.1 and has no auth by contract: on a local hub the operating system is the boundary, and anything that can reach loopback can already read your files. Two defences guard the edges of that assumption:

signalbox hub with no flags stays loopback, unauthenticated and zero-config.

Jump executes locally, but anyone can ask built

Jumping spawns tmux against a socket path on the local filesystem, or activates a local window. It acts on the machine it runs on. Every event carries host, so a session on another machine can be seen from anywhere but jumped to only by its own host.

State is shareable while execution stays local, so the hub is free to move. Sharing a hub does not break jumping, because jumping reads a row and then acts locally.

A phone does not jump, and does not need to: the machine that can jump is already listening to the hub. The phone asks, and the laptop answers:

A command in, an event back. The phone never touches a window.
iOS apptaps a row
POST /command ──▶ {ok, delivered}
hubfans out, forgets
event: command ──▶ every machine
menu bar appis this me?
signalbox jumpraises the window
↳ then seen goes back through the log as a normal event, and the phone's row goes quiet. That is the confirmation.

Commands are not events

The hub logs every event and replays the log to whoever reconnects. A command must never enter that log: a replayed jump moves a window hours after the tap, and a client reconnecting from seq 0 would replay every jump ever fired. So commands skip the log, the reducer and seq entirely - they exist only in flight. The full shape is in events.md.

A command goes out, an event comes back. The request is ephemeral because it is only meaningful now; the result is a fact, so it is logged like any other. jump already fires seen once the switch has landed, so the board going quiet confirms success.

Every machine sees every command

The hub does not route and does not adjudicate. It fans a command out to everyone listening, and each machine decides whether it is the target. That keeps the hub a bus rather than a broker, and it means the decision lives where the knowledge is.

The executing machine requires both the command's target_host (what the caller read off the row it tapped) and the session's own host to name it, and fails closed on a session it does not know. Requiring both makes a disagreement - a stale row on a phone, a session that moved - a silent no-op rather than a jump on the wrong machine.

Two consequences for any surface on a shared hub

Remote: one hub, many machines proposed

Points every machine at one hub so the board spans them, and so a phone can reach it. The hub is the same binary and the same API; what changes is where it binds and what it demands.

Self-hosted. You run this; nobody runs it for you.
mac 1
hooksSIGNALBOX_URL + token
menu bar appjump: local rows only
mac 2
hooksSIGNALBOX_URL + token
menu bar app
HTTPS ──▶ Bearer token
signalbox hubnetwork HTTPS, native or platform TLS
events.jsonlon a volume
HTTPS ◀── Bearer token
iOS appread + ack, never jump

Two listeners

LocalNetwork
Bindhttp://127.0.0.1:8377, alwayshub.network.bind, optional; HTTPS on port 8378
Authnone (process boundary)SIGNALBOX_TOKEN, Authorization: Bearer
TLSnot neededQR-pinned certificate on a LAN, normal certificate on a public host

Both listeners feed the same hub and store. An unset hub.network.bind keeps the hub local. 0.0.0.0 listens on every IPv4 interface; a specific address limits the listener to that interface. Using an address rather than a boolean keeps the exposure explicit.

Two jobs, one check

The loopback Host check currently does two things: it defends against DNS rebinding, and it stands in for authentication. Going remote splits them. The rebinding defence survives as a hostname allowlist; auth becomes real. The token replaces authentication only, and does not cover DNS rebinding.

Binding wide without a token is a configuration error

A non-loopback bind with no token configured must refuse to start, with an error naming what to set. It fails hard rather than warning or offering an opt-past flag. The alternative is a board of everyone's prompts on the open internet.

Offline behaviour

A remote hub needs the network. Emitters spool to disk and drain on the next hook invocation, so events are delayed rather than lost, and the hook path never blocks a calling agent. Surfaces cannot read while offline. This is a documented contract. Local-first stays available to anyone who does not opt in.

Remote hub: self-deployed built

This is the same binary on a routable host: run signalbox hub --remote and point every client at it. It is self-deploy only. Signalbox does not run a hosted multi-tenant service.

One switch, four inseparable effects

--remote, or SIGNALBOX_REMOTE=1 / true, is one security mode. It does all four of these together:

Peer addresses prove nothing

There is no trust-proxy knob. Behind a proxy the peer address belongs to the proxy, not the client, so it cannot establish trust. Forwarded headers do not fix that unless every hop is trusted, because a client can forge them. Remote mode instead stops believing peer addresses at all.

Only GET /healthz and POST /pair remain unauthenticated. The first exists for platform health checks. The second accepts a one-time pairing code, and that code is the credential.

Deployment shape

The Dockerfile, its per-Dockerfile ignore file, and the Fly template live under components/deploy/. The repository root is the Docker build context because the image compiles the CLI source, but the Dockerfile copies only the CLI files needed to build. The image runs remote mode, keeps state on a mounted /data volume, and is deployed as one instance because the hub is the single source of truth. Image publishing is a standalone workflow that a release calls directly and a human can dispatch with a version.

Pair against the public URL

Minting a pairing code needs the bearer from SIGNALBOX_TOKEN. The QR carries the remote hub's HTTPS URL with no certificate pin, and the phone validates its certificate against system CAs. Redeeming the code at POST /pair remains unauthenticated because the code itself authorizes that one exchange.

Machine identity

Events carry machine, a stable <hostname>-<6 hex> identifier persisted per machine, while host remains the display name. The distinction matters because two laptops on one remote hub can share a hostname. The stable value is groundwork for unambiguous command routing, but routing still keys on host today. Command routing by machine is a separate, later piece of work.

Direct clients remain supported

The local forwarder now closes the credential gap without adding a second source of state. Direct access remains the simple option for a machine that does not run a forwarder: its adapters point at the remote hub and carry the token themselves.

Security tiers proposed

Ranked by security per line of code we write. Pick per deployment; they are not exclusive.

TierWhat secures itCost to signalbox
Loopback
the default
The OS process boundary. Zero. Built.
Private network
Tailscale, WireGuard
The tunnel. Identity, device revocation, TLS and NAT traversal all handled outside signalbox. Nothing is exposed publicly. Nearly zero: a --bind flag and a page of docs. Web push still works over a tailnet - it needs an HTTPS origin and outbound reach to the push service, not inbound.
Public + token
Fly.io, a VPS
One shared bearer token. TLS from the platform. Small. Built; see the remote hub tier above.
Public + identity
GitHub device flow
A GitHub identity checked against an allowlist. No shared secret on the hub. Real, and additive. See below.

Tokens stay for machines

Hooks, CI runners and the CLI are machines. They cannot complete an interactive flow, so they need a machine credential regardless of any identity layer. GitHub auth replaces the human login step only. Tokens come first.

Identity removes the shared secret from every hub. Each hub configures an allowlist (SIGNALBOX_ALLOW=dwmkerr) rather than a secret; a client authenticates once with GitHub and presents that identity to any hub, which validates it independently. One identity, many hubs, no passwords to leak or rotate. Device flow needs no client secret, so a self-hosted hub stores nothing sensitive.

The risk

A remote hub holds a breadcrumb of every prompt you type and every reply your agents give, across every machine you point at it, on someone else's server. A single shared token is the only thing protecting it: anyone holding it can read every breadcrumb and forge events. It does not expire, and rotating it means redeploying the hub and updating every machine. Use SIGNALBOX_PROFILE=redacted where even one line must not leave.

The realistic leak vector is the token sitting in shell history, env files and CI configs across several machines; a 32-byte random token is not guessable by brute force. Reducing the number of places the token lives matters more than making the token longer.

Scopes, once commands exist

Once one token also authorizes POST /command, a single all-powerful token no longer holds up. The credential splits along the same machine-versus-human axis everything else does:

ScopeGrantsHeld by
emitPOST /eventsa hook, a CI runner - can announce work, cannot read your board or command anything
read/state, /streama dashboard, a phone that only watches
actuatePOST /commandthe highest-risk scope, hardest to obtain

The stronger form, to aim for before commands carry more than a jump: authority to command belongs to the target machine. The hub token gets you onto the bus; whether your laptop acts on a command is the laptop's decision, from its own allowlist of device identities it has accepted once. Holding the hub token lets someone watch; commanding your laptop needs your laptop to have accepted that device.

And if commands ever carry messages

The obvious next command is message: send text to an agent, not just jump to it. It is not built; this section exists so the door it opens is opened deliberately. Three things change the moment a command carries content:

Jump is a safe place to have crossed into a control plane: the worst a stray jump can do is raise a window. Learn the shape here; do not ship a content-bearing command until the scopes above and per-machine authority are real.

Rejected: two hubs replicating proposed

The tempting shape is a local hub per machine that syncs to a shared one: local-first writes, a remote aggregate for the phone. Do not build this.

Store is not a CRDT. It is last-write-wins on arrival order, not on ts: any agent event clears acked, so an ack at t=6 that arrives before an agent event at t=5 is silently wiped by the older event. Two hubs ingesting in different orders diverge. seq is per-hub ingest order, so ?since=N is meaningless across two of them, and a downlink echo loops because nothing dedupes on event id. The cost is a distributed system for a list of terminal sessions.

If shared state ever needs to survive a partition, the reducer becomes a real CRDT first, as its own piece of work.

The local forwarder built

Run signalbox hub --upstream <url> to put a forwarder on loopback. The upstream resolves from --upstream, then SIGNALBOX_UPSTREAM, then persisted hub.upstream. That persisted key is what turns the app-spawned signalbox hub --port <port> into a forwarder even though the app passes no upstream flag. Local clients stay unauthenticated and unaware; the forwarder alone attaches the upstream bearer, resolved from SIGNALBOX_TOKEN and then persisted hub.token.

Optional. The truth stays remote; nothing is replicated.
your mac
hooksno token, no config
menu bar appunchanged
tmux pickerunchanged
loopback ──▶
hub --upstreamproxy, no store
HTTPS + token ──▶
remote hubthe truth

What it buys, in order of how much it matters:

Writes are store-and-forward

POST /events appends the event to forward-spool.jsonl in the state directory, returns 202 {"spooled": true} immediately, and kicks an asynchronous drain. The hook path has a 200 ms POST timeout. Awaiting a WAN round trip would make the hook spool the same event that the forwarder is already delivering, so the drain is the only sender.

The spool holds at most 10,000 events or 16 MiB, whichever comes first. It drops the oldest events at the bound and drains oldest-first when the uplink recovers, so a wifi drop delays events rather than losing them under normal pressure. Spooled events carry no seq; the upstream assigns the only sequence when it ingests them.

One source of state

The read cache is an in-memory Store fed only by the upstream downlink stream. The local GET /state response comes from that cache, so surfaces retain their last view through an uplink drop. The local write path never applies an event to it: the event goes up, comes back with its upstream seq, and applies exactly once. A downlink echo therefore cannot loop. The forwarder originates nothing, so there is no conflict to reconcile and nothing to dedupe by event id. That is precisely what separates it from Rejected: two hubs replicating above, where both sides originate state.

Commands stay live

POST /command goes upstream synchronously and is never spooled because a stale command has no value. command frames come back down and are re-broadcast on the local stream. That is how the menu bar app's executor still receives a jump requested from a phone.

The uplink is visible

GET /healthz reports url, connected, lastSeq and spooled under upstream, so a local surface can show whether the link is up. On an uplink reconnect the forwarder drops its downstream streams so every client resyncs from /state - that is how a seq-domain change (a switched or replaced hub) heals with no client-side protocol change.

Pair with the hub that owns the credential

A forwarder returns 409 from POST /pair/new, GET /pair/status and POST /pair, pointing the user to signalbox pair --url <upstream>. Pairing trades a code for the hub's own bearer token. A forwarder holds neither the pairing slot nor the authority to hand out the upstream credential from an unauthenticated local endpoint.