Three roles, and every signalbox deployment is some arrangement of them:
session_key, persists them, and
broadcasts. Exactly one process owns the truth.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.
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).
The default, and the only shape that exists today. Everything is loopback.
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.
events.jsonl is the only durable artifact; the in-memory
store rebuilds from it on boot and seq continues from the highest persisted value. A
host needs one process and one writable file. Nothing else.ingest() - including the
expiry and liveness sweeps, which fire real ended events rather than mutating state
behind the reducer's back. Do not add a second write path.Store has no I/O, no clock, no network. It is the
product's semantics in about a hundred lines, and it is testable because of that.hub.handle() takes a
Request and returns a Response, so tests drive every endpoint without a
socket.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:
Host header is not a loopback literal are rejected. A hostile page
can point a DNS name it controls at 127.0.0.1 and read /state
same-origin; this is the DNS rebinding defence.application/json may POST. text/plain and form encodings are CORS
"simple requests" that a hostile page can send with no preflight.signalbox hub with no flags stays loopback, unauthenticated and zero-config.
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:
seen goes back through the log as a normal event, and the phone's row
goes quiet. That is the confirmation.
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.
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.
host when there is more than one, and must make clear which rows can
be acted on. On a local hub every row is local, so nothing needs saying. Once two machines share a
hub, rows must name their host.origin.kind is the extension point. It already names ssh as a future
kind: a row on a machine that is not next to you could one day jump by opening an SSH session
rather than being inert.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.
| Concern | Local | Remote |
|---|---|---|
| Bind | 127.0.0.1 | --bind 0.0.0.0 |
| Auth | none (process boundary) | SIGNALBOX_TOKEN, Authorization: Bearer |
| Host check | loopback literals only | allowlist the configured hostname |
| TLS | n/a | terminated by the platform |
| Offline | always works | does not work, by contract |
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.
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.
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.
Ranked by security per line of code we write. Pick per deployment; they are not exclusive.
| Tier | What secures it | Cost 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. |
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.
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.
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:
| Scope | Grants | Held by |
|---|---|---|
emit | POST /events | a hook, a CI runner - can announce work, cannot read your board or command anything |
read | /state, /stream | a dashboard, a phone that only watches |
actuate | POST /command | the 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.
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:
actuate credential that can
jump is annoying. One that can instruct an agent with tool access is remote code execution by proxy.
actuate must be the hardest scope to hold, and per-machine authorization becomes
required.redacted strips what departs. A message is content
flowing in, and redacted cannot strip a payload that is the entire point of the
request. Inbound content needs its own contract.message command is where the hub stops being a pure bus.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.
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 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.
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.