ADR 0161: A fleet seat reads its mail instead of its keyboard
Status: proposed (2026-09-06), for James to accept with the first message that
lands as a channel event. Amends
ADR 0135 (how send reaches a
persona's pane) and extends
ADR 0152 (the head's outbox and
channel) to every Claude Code seat in the fleet.
Context
A message to a fleet agent — a DM from the app, or a group-chat turn — reaches
its pane as keystrokes: herdr pane send-text writes the text into the pty and
a second command presses Enter. The pty is the same stdin the operator's
keyboard feeds. Nothing on either side can see the harness's composer, so a
message that arrives while the operator is half-way through typing in that pane
is appended to their draft and the Enter submits the mixture. Herdr cannot
guard this: agent prompt refuses a blocked pane, but a draft leaves the
pane idle and no metadata field exposes it.
Claude Code sessions already have an input that is not the keyboard. Its peer
tools (SendMessage) and its channels research preview both deliver text as an
inbound event that starts or queues a turn, with the composer untouched. The
head seat uses exactly this: clankie mcp long-polls the seat outbox and pushes
each wake, watch, or escalation as a notifications/claude/channel event
(ADR 0152). Worker seats had no such door.
Decision
Every fleet seat has a mailbox, and a Claude Code seat reads it through a channel bridge of its own. The pty is the fallback, not the lane.
- One mailbox per seat. The service keeps a
SeatOutboxper seat id (the herdr terminal idsendToSeatalready takes), created on first use. The outbox is the head's type, with bound meaning what it says: a poller is parked, or one returned within a two-second grace that covers the bridge's re-poll gap. A taken event isdeliveredonly when the bridge comes back for more; a bridge that never returns within the grace settles itunboundand the caller runs the pty. A newer parked poll supersedes an older one, so a resumed session cannot have its mail taken by a stale one. - The pty is the fallback. The seat sender tries the mailbox first and types into the pane only when no bridge is bound. A Codex or pi seat, or a Claude Code pane launched without the channel, behaves exactly as before.
- A message is its own event kind.
OperatorSeatEventKindgainsmessage; the event carries the conversation and asourceofoperator(a DM) orroom(a group-chat turn). The head never receives one. - The bridge is channel-only.
clankie mcp --seatserves no tools and noreply: a worker answers in its own transcript, which the transcript projection already harvests into the thread, so the reply path does not move. It identifies itself byHERDR_PANE_ID, which herdr sets in the pane and Claude Code passes to its MCP servers; the service resolves the pane to the seat. It polls with the operator bearer from the broker, like the head bridge, and reads only its own pane's mailbox. - 404 is an early state. The bridge starts before herdr has classified
the harness, so
unknown_seatis retried quietly rather than treated as a failure. - The hire path loads the channel. Channels are a per-launch opt-in with
no persistent setting, and the
server:form of the flag binds only a server in Claude Code's persisted config, never one handed over with--mcp-config(probed 2026-09-06: the harness starts the process but reportsno MCP server configured with that name). So a seat hired from the app for theclaudeharness getsclankie-seatregistered once at user scope (claude mcp add -s user clankie-seat -- clankie mcp --seat) and is started with--dangerously-load-development-channels server:clankie-seat. That flag stops every launch at a "Loading development channels" dialog before the TUI is usable; the hire path reads the pane, recognizes that dialog and nothing else, confirms its preselected "local development" option, and waits for the agent to settle. The approved form,--channels server:clankie-seat, starts without the dialog but then rejects aserver:entry as not on the allowlist, so the development flag is the one that binds. A pane the operator opens by hand gets the mailbox only if launched with the flag; otherwise it keeps the pty lane. - The bridge polls only when the channel is bound. A user-scope
registration means every Claude Code session on the machine spawns the
bridge, including ones started without the flag, whose harness would drop
each notification on the floor while the mailbox read as bound — a black
hole with no pty fallback. The bridge therefore reads its parent process's
argv and polls only when that launch named
server:clankie-seatto a channels flag; otherwise it serves an empty channel and never binds.
Consequences
- A DM or room turn to a channel-loaded Claude Code seat never touches what the operator is typing there. The transcript, the thread, and the room's harvest are unchanged.
- The fleet-wide
SeatSendercontract carries the conversation and source, so a mailbox event can say where it came from. - A pane moved across workspaces loses its mailbox until the harness is
relaunched: herdr stamps
HERDR_PANE_IDat spawn and a public pane id is per workspace, so the bridge keeps polling an id herdr no longer resolves and the seat falls back to the pty. Polling by the stable terminal id is the follow-up. - The route is a new power on the operator bearer: any process holding it can dequeue any seat's mailbox, where ADR 0135 only let it send. Every such process is the owner's on the owner's machine, which is why the head outbox accepted the same bearer; a per-seat poll token minted at hire is the follow-up that closes it.
- A message queued but not yet taken when the service restarts is typed into the pty by the fallback, the one window where the draft is still at risk.
- Room harvest is unchanged and unchanged means the pre-existing weakness
stays:
awaitSeatReplytakes the seat's next agent sentence from any thread, so a preamble before a tool call, or a concurrent DM reply, can be read as the room's answer. Correlating the waiter to the mailbox event is the follow-up. - Herdr reports a Codex session after the pane's first turn, so reading the rollout file is the interim; the session herdr reports is its replacement.
- A Codex seat takes its message through
codex queue --thread <session>, which the harness runs as its own user turn with the composer untouched (probed 2026-09-06). Herdr reports no session for Codex, so the service reads it off the rollout file the runningcodexprocess holds open,rollout-<timestamp>-<uuid>.jsonl, via the pane's foreground process andlsof. A pane that has never spoken has no rollout yet and keeps the pty lane; so does any failure to resolve or queue. - pi and Grok seats still take keystrokes. Extending delivery to them waits on those harnesses growing an out-of-band input.
