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.
| Local | Network | |
|---|---|---|
| Bind | http://127.0.0.1:8377, always | hub.network.bind, optional; HTTPS on port 8378 |
| Auth | none (process boundary) | SIGNALBOX_TOKEN, Authorization: Bearer |
| TLS | not needed | QR-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.
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.
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.
--remote, or SIGNALBOX_REMOTE=1 / true, is one security mode.
It does all four of these together:
0.0.0.0.SIGNALBOX_TOKEN must come from the environment. A missing or empty token is fatal at boot;
the hub never generates one on an ephemeral container filesystem.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.
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.
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.
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.
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.
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. 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. |
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.
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.
What it buys, in order of how much it matters:
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.
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.
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.
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.
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.