Specification: signalbox for iOS

The board on your phone: what needs you, from away from the desk.
Note: the mockups below are the living specification for the signalbox iOS app.
9:41
writing-studiombp Shall I force-push the rebased branch, or open a new PR?
agent-notifierstudio Done. The reducer now drops empty optionals, and the tests cover it. SeenHide
ci: releasegithub Job failed: typecheck (2 errors in components/cli/src/hub.ts)
π crash-dumpsmbp Working… symbolicating the last 40 reports
release-notesstudio Drafted the 0.2.0 notes from the merged PRs.
Sessions
Settings
Sessions. Tap a card to expand, the arrow or a right-swipe to jump, left-swipe for Seen and Hide. Every machine row carries the arrow, including the plain-terminal release-notes row. The URL-only ci: release row is information and carries no arrow.
9:41
Not connected
Choose 'Connect Phone' on the desktop app, or run 'signalbox pair' from the CLI.
Scan to Connect
Sessions
Settings
No hub yet. Instead of an empty board: the hint, and the way in right under it.

What this is built

The product is "know when a session needs you without watching terminals". Away from the desk is where it matters most: agents run long, and the amber moment usually lands once you have walked away. This surface is the board in your pocket.

It is a native app. That keeps the hub simple: a browser cannot set headers on an EventSource, so a web board would force cookies, sessions, a login page and CORS onto the hub. URLSession sets an Authorization header freely, so the hub stays a pure API and the phone is another client.

Scope

The phone is a remote control

Jumping spawns tmux against a socket on a local filesystem, or raises a local window. A phone has neither, so it cannot jump itself. The laptop can: it is already listening to the hub and already knows how to jump, so the phone asks it to.

The ask is a command on the same bus the board rides. Fire the jump from a card with the arrow or a right-swipe, and the machine that owns that session raises the window. The phone in your hand becomes a jumplist for the laptop on your desk.

Three rules:

Activating Jump on a machine row with no usable origin presents an alert titled Jump not supported with this message: "This session is running in a plain terminal - jump is not supported yet. Submit feedback if you would find this useful." Submit feedback opens https://github.com/dwmkerr/signalbox/issues/67; Cancel dismisses the alert.

origin.kind reserves ssh as a future kind. When it lands, a row on a machine that is not next to you becomes jumpable too.

Rows

Same anatomy and the same words as the jumplist, so the two surfaces never disagree: status mark, agent glyph, name, host, age, and the subtext.

Status marks

A bold name means unread. The amber mark alone means the session is asking for you; there is no extra badge.

Subtext

The line under each name follows the one-line preview rule from agent-markdown.md: take the first paragraph, keep inline styles, and collapse whitespace to one line. While a session works you see your prompt (what it is working on); once it finishes, asks, or fails you see its reply (what came back). Identical rule to the jumplist.

Host

A row shows the machine it lives on when more than one machine is on the board. The machine decides whether a row can be acted on, so it matters most on a shared hub. With one machine every row shows the same host and the state line already says "1 machine", so the chip drops out.

Icons and naming

The glyph comes from the event's agent field, mapped locally to the same per-agent icons the jumplist uses (see the table), with the plain ring as the fallback for anything unknown. Naming priority is label, then title, then the cwd folder name.

Order and filtering

The hub owns the order. GET /state returns rows in display order (most recently engaged first) and the app adopts it verbatim. Do not re-sort client-side: the ordering rules live in the reducer, and a surface that re-derives them will drift.

The search field lives in the navigation bar's drawer: it is hidden until you pull the list down, which keeps the board itself uncluttered. Search filters live over name, prompt, reply and agent. A query starting with # switches to tag mode (#ops shows only sessions carrying that tag, case-insensitive, exact match), matching the jumplist. Hidden rows drop out of the main list (see below); acked rows stay, drawn quiet.

Pinned built

Long-press a card and choose Pin (the Messages / Telegram gesture). The session stays at the top of the board with a small pin mark, regardless of engagement order. Long-press again to unpin.

Pinned rows sort first. Within the pinned group, and within everything below it, the order is still the hub's engagement MRU. The hub owns the order, so pin and unpin are user events like seen and hide: the reducer serializes pinned, and every surface (this app, the jumplist, the menu bar) shows the same partition.

Hiding a pinned session unpins it. Hide is the stronger, more recent intent.

Hidden built

Hidden sessions collapse into a quiet divider at the bottom of the list - the Reminders "Show Completed" pattern. The section holds only sessions that are currently silent and deliberately hidden: a hidden session that speaks again returns to the main list on its own, so the section stays small.

Empty states

An empty board means three different things, and the app says which - and the board agrees with Settings rather than telling a different story on reload. On a live hub with nothing running it is the quiet moon: No sessions. Fire an agent and it appears here.

A hub that is configured but unreachable is Hub offline, not "not connected": the board is a memory and says so: Can't reach hub at <host>. Some corporate networks will block device to device connections. Deploy a remote hub, or connect via a mobile hotspot (see docs). The message names the likely cause because the failure is otherwise silent - a phone reaches the hub over the LAN, and a corporate/guest network that isolates devices drops the connection with nothing to show. The same message appears on the top banner when the board still has cached cards (not only on the empty-board screen), so tapping Reconnect against an unreachable hub always explains itself. The way back is Reconnect (retry now, skipping the backoff), Scan a New Hub, and a destructive Disconnect behind the same confirm as Settings. A rejected token is its own line - a retry cannot fix it, so it leads with Scan to Re-pair. This is the state a relaunch lands on when the hub is down, and it must match the Offline row in Settings, not fall back to the first-run pitch.

Only a hub the phone was never paired with shows Not connected, pointing at the qrcode.viewfinder button top-right. The words name the desktop app and the CLI - the two ways a power user pairs - and deliberately do not send them to Settings, which only repeats the same scan.

The bottom menu

TabWhat it is for
SessionsEvery session on the hub, in the hub's order, as cards. The one surface: browse, search, ack, and jump.
SettingsThe hub and pairing. Machine and session counts live on the Sessions connection line.

Actions

GestureAction
Tap a cardOpen the session's history as a conversation (see Chat below), and mark it read. Opening the thread is reading it, and a dot that survives being read stops being worth trusting. Mark as Unread is the way back. On a phone you are not at the machine the session runs on, so reading what happened is the useful default and jumping is the deliberate act - which is why jump keeps its own button rather than owning the whole row.
The dot on the trailing edgeWhether this row wants you: amber when it needs your input, blue when output has updated, absent once it is read. It is the row's only mark. What the agent is doing is not a mark: a working session shows a small spinner, the word Working..., and the prompt it is working on, in italic, on its subtext line. The italic is load-bearing: that line quotes you while working and the agent once done, and rendered alike the two cannot be told apart without reading them. Amber was rejected because amber already means "needs your input". which is where messaging apps put activity - WhatsApp and Telegram replace the message preview with "typing...", and none of them keeps a glyph column for it. Idle rows therefore say nothing at all, which is the point: the default posture stays silent. Elapsed time is the age column's job, so the subtext does not repeat it. Information-only rows (CI, GitHub) carry the dot but no jump affordance, which is the clearest proof that wanting you and being actionable are independent.
The arrow, JumpShown on every visible machine row. A tmux or cursor origin asks the owning machine to raise its window. With no usable origin, it shows Jump not supported: "This session is running in a plain terminal - jump is not supported yet. Submit feedback if you would find this useful." Submit feedback opens issue 67, and Cancel dismisses the alert. URL-only CI and GitHub rows are information-only and offer no arrow. A successful jump marks the session seen, matching the jumplist. Jump is deliberately not a swipe: it reaches across to another machine and moves a window, which suits a button press rather than a gesture.
Swipe right, Read / UnreadToggle the read state, the way Messages and Mail put it on the leading edge. A toggle always does something, so unlike a one-way Seen it needs no special case to stay honest on a row that has already been read. Also in the long-press menu, as those apps offer both.
Swipe left, HideFire hide. The row leaves the main list for the collapsed Hidden section until the session speaks again, or until unhidden there.
Long-press builtContext menu: Pin / Unpin (see Pinned above) and Mark as Read / Mark as Unread. Chat is the row's tap, so it is not repeated here.
Pull down a littleReveal the search field.
Pull down furtherForce a full /state resync. The escape hatch when a stream has gone strange.

Expanding a card does not mark it seen. On the desktop, jumping is the ack because looking at the pane is seeing it. On a phone, reading a breadcrumb is not dealing with the session, so marking seen is always deliberate here.

The board offers no way to remove a session. Removal is destructive and irreversible from the phone.

Chat

Tap a session row to open its history as a conversation.

9:41
writing-studio
older
Update the history reducer and its tests.
9:31
Done. The reducer now keeps each session's exchanges in a bounded ring.
9:31
Add the read endpoint.
9:35
Added GET /exchanges with limit and before paging.
9:36
Build the iOS chat page.
9:40
Chat is ready.
- Markdown renders
- Older turns page from the top…
9:41
The newest three exchanges. Prompts sit right, replies sit left, and the top … loads older turns only while another page may exist.

Each prompt and reply carries a short timestamp below it. Markdown is rendered according to agent-markdown.md, including headings, emphasis, links, lists, code and tables. A cropped exchange ends in . The page opens with the newest three exchanges; when all three slots are filled, the … older affordance at the top requests the previous page and prepends it. A shorter page removes the affordance because it proves the start of history has been reached.

An empty history reads No history yet. A failed request reads Could not load the chat and offers Retry. Opening the page marks the session read, and its trailing nav-bar button jumps to the session; it never hides a session.

Connection state

A remote hub needs the network, so the app always says what it is showing. The state line under the hub name is always visible, so you can tell a stale board from a live one.

StateLineMeaning
LiveConnected to 192.168.1.94 · 2 machinesStream connected. What you see is current.
ConnectingReconnecting…Retrying with backoff. Rows stay, drawn normally.
OfflineOffline · last seen 4m agoUnreachable. The board is a memory and says so.
RejectedHub rejected this token401. Send them to hub settings, do not silently retry.

Fail fast, and show the attempt. The one-shot /state probe and the pairing redeem run on a short-timeout session (~10s), separate from the long-lived stream (which keeps its ~90s idle timeout so a quiet live stream is not killed). A phone that cannot see the hub - a corporate/guest network that isolates devices - therefore fails in seconds, not the 60-90s a default timeout would hang for. One failed probe is not a verdict, though: right after launch or foreground the network path is often still waking, so failures inside a short grace window (~2.5s) after a (re)start stay on the quiet Connecting state while the loop retries. Offline is declared on the first failure after the window closes (a stale offline verdict stands until a probe succeeds - it never flips back to Connecting on its own). This is what stops the sessions page flashing "Hub offline" for a second on every open.

Sessions are built per connection attempt, never once at startup. Each attempt (probe, stream, command, redeem) constructs its own URLSession carrying the current hub's url, token and cert pin together, and tears it down after - so no connection can mix one pairing's URL with another's pin, and the pinner itself is immutable. This is load-bearing, not style: TLS server-trust challenges were observed never reaching the delegate of sessions built once at init (every pinned LAN handshake failed as if unpinned, flapping the board live/offline each second), while call-time sessions verify correctly. Probes are never cached: requests use a no-cache policy and the hub sends Cache-Control: no-store on /state - a cached probe kept a dead board looking alive (stale rows, fresh last-seen) for as long as the cache held. While an attempt is in flight the app always shows it: pairing puts up a Pairing with <host>… progress HUD, and a Reconnect from the offline banner shows a Reconnecting… spinner banner (only when recovering from offline, so a plain foreground connect does not flash it). Never a tap that looks like it did nothing.

Reconnect with ?since=N carrying the highest seq seen, so the hub replays the gap. That is the primitive a phone needs across a tunnel, a lift, or a suspended app. Do not poll /state in a loop instead.

iOS suspends the app and the stream dies with it. Reconnect on foreground with ?since=N. Do not fight for background execution; that is what push is for, and push is a later piece of work.

Settings

9:41
Settings
Hub
StatusConnected
Address192.168.1.94:8377
Token••••••••
The hub is the machine you connect to that provides your session details.
Disconnect
Sessions
Settings
Connected: read-only facts, one destructive action at the bottom.
9:41
Settings
Hub
StatusNot connected
The hub is the machine you connect to that provides your session details.
Scan to Connect
Enter Address Manually
Sessions
Settings
Not connected: Scan to Connect on top, manual as a row beneath.
9:41
‹ Settings Enter Address Connect
Addresshttp://192.168.1.20:8377
TokenOptional
Leave the token empty for a hub with no password.
The manual screen: address, optional token, Connect in the nav bar.

Settings holds one thing: the hub you connect to. Connecting is a mode, so the screen has two faces.

Connected

Not connected

Data and storage

Pairing built

Run signalbox pair on the CLI or use Connect Phone from the desktop app. Each encodes the hub URL and token into a short-lived QR code the app scans to connect. The manual address and token fields stay for when there is no QR.

Scanning is a main-screen action:

The link is the contract; the QR carries it:

Pinned LAN: signalbox://pair?url=<percent-encoded "https://LAN-IP:PORT">&code=<base64url-code>&fp=<cert-pin>
Remote:     signalbox://pair?url=<percent-encoded "https://hub.example.com">&code=<base64url-code>

pair rides in the URL host (a custom scheme has no path authority). url is the hub to redeem against; code is a one-time pairing code. The phone POSTs { "code": <code> } to <url>/pair and gets back { "token": <string> }; any 4xx means the code is bad, expired, or spent. The token goes to the Keychain and the app reconnects.

An unreachable pairing target gives advice for the kind of hub the link names:

TargetMessage
Pinned LAN, or plain httpCould not reach <host>. The phone and the Mac must be on the same Wi-Fi - and some hotel and office networks stop devices seeing each other even when they are. If that is what is happening, run a remote hub instead: see "Run a remote hub" in the signalbox docs.
Remote https with no pinCould not reach <host>. Check the phone's internet connection, and that the hub is running.

A pin identifies a LAN hub; plain http is LAN too because it has no TLS to pin. An https target with no pin is remote. "Same Wi-Fi" is wrong advice when a network uses AP isolation to stop devices seeing each other, and a remote hub is the product's answer to that.

Pinned TLS (#25). When devices are allowed the hub serves the phone over https with a persisted self-signed cert; the LAN's plaintext gap - a same-network attacker reading prompts/replies or forging events - is otherwise the one real exposure. fp is the SHA-256 of the cert's DER, which the app pins in a URLSession server-trust delegate (compared against SecCertificateCopyData's hash) on every request: any other cert is refused, so a self-signed cert with no CA still gives MITM-proof transport. fp is present for a LAN hub's self-signed certificate and absent for a remote hub behind platform TLS. An https link with no fp is valid and uses system CA validation. An https link with a malformed fp is rejected outright: a corrupted or crafted pin must never silently downgrade to system trust. The server-trust delegate therefore has three branches: no pin uses default handling and system CAs; a set, matching pin accepts the certificate; a set pin that does not match, or a missing certificate chain, cancels the challenge. A self-signed LAN certificate still fails system validation when no pin is present, so nothing unverifiable is ever trusted. A pin lives in the Keychain beside the token when one is set and is cleared on disconnect. The redeem POST rides the same verified https connection, so the token is never handed over before the server is trusted.

A hub typed into Settings carries no pin. A hand-entered https:// address connects under system trust, while a hand-entered http:// address stays plain HTTP.

Two security invariants:

The deep link is what the tests drive; the scanner is one way to deliver it. The camera cannot run in the simulator, so the scanner guards on availability and falls back to the same instructions a device without a camera shows. NSCameraUsageDescription and the signalbox URL scheme live in Partial-Info.plist.

Notifications later

The point of the app is to be told when a session needs you. v1 ships without notifications to get the board and stream right first; the design lands in its own spec.

Shape, so v1 does not foreclose it: attention and error always notify, done only after more than thirty seconds of work (quick turnarounds are noise), keyed by session so a newer signal replaces a stale banner. The same rules as the menu bar. Web push works over a tailnet, and the hub sends outbound to the push service, so no tier of the deployment model rules it out.

What the app must not do