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 hub0.0.0.0:8377, TLS at the platform
events.jsonlon a volume
HTTPS ◀── Bearer token
iOS appread + ack, never jump

What changes

ConcernLocalRemote
Bind127.0.0.1--bind 0.0.0.0
Authnone (process boundary)SIGNALBOX_TOKEN, Authorization: Bearer
Host checkloopback literals onlyallowlist the configured hostname
TLSn/aterminated by the platform
Offlinealways worksdoes not work, by contract

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.

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. The work described 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.

Accepted: a local proxy proposed

The safe version of the same instinct. A local signalbox hub --upstream <url> keeps no state of its own: it forwards POSTs upstream with the token attached and relays the upstream stream back down. Every other client on the machine keeps talking to loopback, unchanged and unaware.

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:

It may hold a read-through cache of upstream state so surfaces still render when the uplink drops. A cache fed by the upstream stream is a materialised view that originates nothing, so there is nothing to reconcile. Writes go up, come back down, and apply.