Probed live against a running Herdr server, most recently re-probed 2026-07-07 and cross-checked
against the bundled machine-readable schema — herdr api schema [--json | --output PATH]
(schema_version 1, covering requests, responses, errors, and events) is now the fastest way to
re-derive this contract without probing. These are the facts the bridge is built on; they confirm
the socket assumptions behind the design in ARCHITECTURE.md.
- Unix domain socket at
$HERDR_SOCKET_PATH(default~/.config/herdr/herdr.sock). - Newline-delimited JSON. Request:
{"id": <string>, "method": <string>, "params": <object>}.idmust be a string (integer →invalid_request).
- Response:
{"id", "result": {"type": "...", ...}}or{"id": "", "error": {"code", "message"}}. - RPC is one-shot: the server closes the connection after a single response. Send one request per connection. (Confirmed: a second request on the same connection never replies — the socket is already closed.)
- Malformed requests close the connection too, and the serde error message names the missing/ wrong field — which is how this contract was reverse-engineered without side effects.
- Exception:
events.subscribekeeps the connection open and streams events.
| Method | Params | Returns (result.type) |
|---|---|---|
session.snapshot |
{} |
session_snapshot → snapshot{workspaces[], tabs[], panes[], agents[], layouts[], focused_*} |
workspace.list |
{} |
workspace_list → workspaces[] |
pane.list |
{} |
pane_list → panes[] |
pane.read |
{pane_id, source, lines, format} |
pane_read → read{text, truncated, revision} |
pane.send_text |
{pane_id, text} |
(ack) |
pane.send_keys |
{pane_id, keys} |
(ack) |
agent.send |
{target, text} |
(ack) — writes literal text, no Enter |
pane.readsource∈visible | recent | recent_unwrapped | detection— snake_case on the wire:recent-unwrappedgetsinvalid_request: unknown variant(live-probed 2026-08-03, herdr 0.7.5; the variant list above is quoted from that error).detection's semantics are unverified.format∈text | ansi.-
recent_unwrappedis a no-op for Claude panes (byte-identical torecentacross working and idle panes, live-probed 2026-08-03): Claude Code runs on the alt screen, so the read is the visible grid, and its renderer hard-wraps prose at the pane width — there are no soft-wrapped rows for the unwrap to merge. It only differs on scrollback-accumulating panes (shells: one probe measured 199 → 188 lines with logical lines up to 222 cols re-joined). -
A
recenttext read can scroll the pane it reads. Herdr's agent-automation docs state that for an idle, recognized agent at the bottom of its transcript,recent/recent_unwrappedreads "automatically use the agent's mouse-scroll interface" whenlinesasks for more than the visible screen, collecting overlapping pages and "returning the viewport to the bottom before completing the read". Claude runs on the alt screen, which has no host scrollback, so that is the only way those rows can be reached — and the operator watches their terminal scroll up and snap back, once per read. Live-probed 2026-08-10 (herdr 0.8.0), idle claude pane,viewport_rows: 71:sourcelinesformatelapsed lines returned recent70 text0.00s 71 recent71 text0.00s 72 recent72 text0.85s 73 recent400 text13.8s 401 recent200 ansi0.00s 71 recent600 ansi0.00s 71 visible600 ansi0.00s 71 The threshold is exactly
lines > viewport_rows; one row over is enough.format: "ansi"was never observed to harvest — that is an observation across the probes above, not a documented guarantee, so don't lean on it.visibleis immune by construction: it is the rendered viewport, clamped to it however largelinesis, so there is nothing above to collect. A background poll that reads panes on a timer must therefore usevisible(bridge/state-engine.ts); anything asking for scrollback is asking to move the operator's screen, and should be a deliberate, user-initiated read.format: "text"returns clean plain text (no ANSI escapes) → safe to render, no XSS surface.
-
agent.sendwrites literal text only; to submit a reply, follow with an Enter keypress (pane.send_keys {keys: ["Enter"]}) — submit-key name needs live confirmation per agent.pane.send_textwrites RAW bytes — no bracketed paste. Live-probed 2026-07-27 (herdr 0.7.4) by sending into a pane running/usr/bin/cat -v, which renders control bytes visibly: the text came back bare, with no^[[200~/^[[201~framing. Two consequences worth keeping:- A PTY is an ordered byte stream, so a following
send_keyscannot overtake the text. Any "the Enter arrived before the text" theory is dead on arrival — including blaming the settle delay between the two calls (sendReplySteps,bridge/server.ts). See #34, where that was the first and wrong hypothesis. - A
\ninsidetextis delivered as a real newline keypress, not as pasted content. What the TUI does with it (submit vs. insert) is the harness's choice, not something the paste framing hides.
- A PTY is an ordered byte stream, so a following
- An ack means "herdr took the bytes", never "the TUI acted on them". Both
send_textandsend_keysreturn before the target program has read, let alone rendered, anything. So a successful RPC pair is not evidence a reply was delivered — a focused TUI dialog can swallow the text and consume the Enter with both calls reporting success. Anything that needs delivery confirmed must read the pane back and look (web/src/lib/reply-action.ts).
Herdr answers no
OSC 10/OSC 11background query, and relays SGR verbatim (live-probed 2026-07-29 in a pane running a raw-modesttyprobe: both queries returned nothing).Two things follow, and both matter to anything that renders pane output:
- A harness that asks what background it is on gets no answer and falls back to dark. Codex emits both queries at startup; with no reply its output is dark-authored (
#f6e2b7,#abdfa7— L=0.774 and 0.642, light values that only make sense on a dark ground). Collie cannot answer either: it reads a rendered buffer downstream of the PTY, so the negotiation happens between the harness and herdr on a channel Collie does not own.- Herdr does not rewrite escape codes into its own theme.
format:"ansi"returns what the program wrote —\x1b[38;5;1mstays a palette index, never a resolved RGB. So herdr's[theme]setting governs how herdr's own UI paints a pane, not what a client receives, and the same palette-index colour can legitimately differ between the desktop TUI and Collie (which applies its own 16-slot table). See.adr/0002.
session.snapshot {} → {"type":"session_snapshot","snapshot":{...}}. One-shot like every RPC —
no special connection handling, no streaming. The snapshot bundles everything a client needs to
bootstrap or resync in a single round trip:
Docs-blessed pattern: bootstrap with session.snapshot → events.subscribe → re-session.snapshot
on reconnect or staleness. CLI mirror: herdr api snapshot prints the raw reply — handy for
diffing shapes without writing a client.
Collie's bridge polls this method (one RPC per tick instead of the workspace.list + pane.list
tab.listtrio) and falls back to the trio on older servers that don't know the method. Old-server detection: the error reply is{"id":"","error":{"code":"invalid_request","message":"invalid request: unknown variant `session.snapshot`, expected one of ..."}}— the bridge treats anunknown varianterror onsession.snapshotspecifically as "fall back," not a hard failure.
The server validates every key and rejects unknown names with
{error:{code:"invalid_key", message:"unsupported key <X>"}} (pane lookup happens first, so probe
against a real pane). Empirically enumerated against Herdr 0.7.0 — it is NOT tmux syntax:
- Special keys (bare, case-insensitive):
UpDownLeftRightTabEnterEscapeSpaceBackspace(aliasBS), and function keysF1…F12. - Literal single characters: a one-character string is typed as that character — digits (
"1","2", …), letters, punctuation (live-verified 2026-07-04). This is what Collie's prompt-select taps send:{keys:["1"]}answers a permission dialog;{keys:["2","Enter"]}picks option 2 of an AskUserQuestion select. - Modifier chords (join with
+):ctrl+c,ctrl+u,ctrl+d,ctrl+l,ctrl+r,shift+tab,ctrl+left,alt+f, … Modifiers:ctrl/shift/alt/cmd/super(case-insensitive). This is the same grammar asconfig.toml [keys]. - Multi-modifier chords work, in any modifier order (live-verified 2026-07-20 against 0.7.3 on
a throwaway sandbox pane, with
PageUp→invalid_keyin the same run as proof the validator was active):ctrl+shift+p/shift+ctrl+p,alt+shift+p/shift+alt+p, triplectrl+alt+shift+p/ctrl+shift+alt+p, and modifier+specialalt+Upall ack. Independently confirmed against 0.7.4 by @bnivanov (issue #20). - NOT supported (all return
invalid_key): tmux-styleC-c/BTab; and the keysPageUpPageDownHomeEndInsertDelete(in any spelling). There is no forward-delete and no scrollback paging via keys — the web mirror is scrollable instead. ⚠️ Consequence: Ctrl-C isctrl+c, notC-c. Multiple keys per call are applied in order, e.g.{keys:["Down","Enter"]}.- Re-checked against 0.7.2's bundled schema: unchanged.
Three sibling RPCs set a display label on a workspace, tab, or pane. Live-verified 2026-07-18.
| Method | Params | label |
Returns (result.type) |
Event |
|---|---|---|---|---|
pane.rename |
{pane_id, label} |
string | null — null clears |
pane_info → {pane} |
none |
tab.rename |
{tab_id, label} |
string (non-null) |
tab_info → {tab} |
tab_renamed |
workspace.rename |
{workspace_id, label} |
string (non-null) |
workspace_info → {workspace} |
workspace_renamed |
pane.renameis the odd one out, twice over. Itslabelacceptsnull, which clears the label (thelabelkey then disappears from the pane record); the sibling two take a non-null string. And it emits NO event — a renamed pane surfaces only on the nextsession.snapshot/pane.listpoll.tab.rename/workspace.renameDO emit:tab_renamed→{type, tab_id, workspace_id, label},workspace_renamed→{type, workspace_id, label}(theeventfield is snake_case on the stream, as everywhere).- Errors: an unknown id →
{code:"pane_not_found" | "tab_not_found" | "workspace_not_found", message:"<kind> <id> not found"}. - No length limit; empty string accepted (stored as-is on tab/workspace). Re-verified on
tab.rename2026-07-19:label:""is stored literally (the tab's label becomes empty — it does not reset to the default number), andlabel:nullis rejected with{code:"invalid_request", message:"invalid request: invalid type: null, expected a string"}— confirming tabs/workspaces have no "clear" (onlypane.renameclears, vianull). Collie makes its own opposite choices per object: a blank pane "Save" clears (blank →null), while a blank tab "Save" is refused client- and bridge-side, since a literal-empty tab chip is useless. Seebridge/server.ts(normalizeTabLabel). - Undocumented field: once set, a pane's label rides along as
label?: stringinpane.list,pane.get,pane.current, andsession.snapshotpanes (omitted when unset — so it's absent from the base pane shape below). Workspaces already exposelabel; tabs likewise. agent.rename{target, name}also exists in the schema, but it is a DIFFERENT operation (renames an agent session, not a pane/tab/workspace) — unverified and unwired by Collie. Listed only so it isn't mistaken for the label renames above.
Two sibling structural ops remove panes. tab.close live-verified 2026-07-19 on the sandbox session.
| Method | Params | Returns (result.type) |
Event | Error (unknown id) |
|---|---|---|---|---|
pane.close |
{pane_id} |
ok |
pane.closed |
pane_not_found |
tab.close |
{tab_id} (schema: TabTarget) |
ok |
tab.closed |
tab_not_found |
tab.closeis a BULK pane-close: closing a tab terminates EVERY pane inside it. Verified by creating a throwaway tab holding a plain shell pane, thentab.close {tab_id}— the nextsession.snapshotno longer lists the tab or its inner pane. So it's no more privileged than closing those panes one-by-one (whichpane.closealready allows) — same remote-shell threat model.- Success is a bare
{"result":{"type":"ok"}}(same shape aspane.close), not a record reply like the renames — there's nothing left to describe. The closure surfaces on the next snapshot poll;tab.closealso emits atab_closedevent (which Collie doesn't consume). - Errors: unknown id →
{code:"tab_not_found", message:"tab <id> not found"}; a missingtab_id→{code:"invalid_request", message:"invalid request: missing field `tab_id` …"}.
Two sibling structural ops reorder objects. Both live-verified 2026-07-20 on the sandbox session.
| Method | Params | Returns (result.type) |
|---|---|---|
tab.move |
{tab_id, insert_index} |
tab_list → that workspace's tabs, post-move order |
workspace.move |
{workspace_id, insert_index} |
workspace_list → all workspaces, post-move order |
- Tabs: array order is authoritative,
numberis stable.tab.movereorders the array returned bytab.list/session.snapshotwithout renumbering — after movingt2(number 2) beforet1(number 1), the snapshot lists[t2, t1]with numbers unchanged. Herdr itself renders array order: the default label of an unlabeled tab is positional (post-move,t2displays as "1").⚠️ Consequence: a client that sorts tabs bynumberun-does the user's reorder — render tabs in array order, never number order. - Workspaces are the opposite:
workspace.moverenumbers. After movingw7(number 5) to the front, it becomesnumber 1and every other workspace shifts —numberalways equals position, so array order and number order never disagree and sorting workspaces bynumberis safe. insert_indexcounts positions in the PRE-removal list (workspace-scoped for tabs, clamped at the end). Moving an item toward the end therefore needstarget + 1: with[t2, t1],tab.move {tab_id: t2, insert_index: 1}is a no-op;insert_index: 2yields[t1, t2].- The event catalog lists sibling
tab.moved/workspace.movedevents (0.7.2); emission not observed here (no live subscription during the probe).
// workspace.list → workspaces[]
{ "workspace_id":"w0000000000000", "number":1, "label":"demo",
"focused":false, "pane_count":2, "tab_count":1,
"active_tab_id":"w0000000000000:t1", "agent_status":"done" }
// pane.list → panes[]
{ "pane_id":"w0000000000000:p1", "terminal_id":"term_…", "workspace_id":"w0000000000000",
"tab_id":"w0000000000000:t1", "focused":false, "cwd":"/…/demo",
"foreground_cwd":"/…/demo", "agent":"claude", "agent_status":"done",
"agent_session":{"source":"herdr:claude","agent":"claude","kind":"id","value":"…"},
"revision":0,
"scroll":{"offset_from_bottom":0,"max_offset_from_bottom":128,"viewport_rows":48} }agent_status ∈ idle | working | blocked | done | unknown. Panes without an agent omit/null agent.
agent_sessionhas TWO kinds, and it can outlive the agent that reported it (live-verified 2026-07-29 against claude, codex and pi panes). Each harness's herdr integration reports throughpane.report_agent_session, and what it reports differs:
kind:"id"+ a uuid — claude (source:"herdr:claude") and codex (source:"herdr:codex", from codex'sSessionStarthook; verified on codex 0.145.0).kind:"path"+ an absolute path to the log file — pi (source:"herdr:pi"). pi's integration prefersagent_session_pathover an id whenever its session manager has a file open.Two consequences. A harness only reports at all once
herdr integration install <agent>has been run — pi's was missing on this host and the pane simply carried no session of its own. And Herdr keeps reporting the LAST session announced for a pane, so relaunching a pane's agent as a different harness leaves the previous one's ref behind: a pane runningpiwas observed still advertising aherdr:claudeid. The record's ownagentfield is what distinguishes the two — compare it against the pane'sagentbefore trusting the ref (bridge/state-engine.ts).
Pane records now carry
scroll(new in 0.7.2, live-verified 2026-07-07):pane.list,pane.get,pane.current, andsession.snapshotpanes all includescroll: {offset_from_bottom, max_offset_from_bottom, viewport_rows} | null(alluint64;offset_from_bottom == 0means the pane is scrolled to the bottom). Collie doesn't consume it yet.
revisionis a stub on Herdr 0.7.x (live-verified 2026-07-05 on 0.7.0; reconfirmed unchanged on 0.7.2, live-verified 2026-07-07):pane.read,pane.list, andsession.snapshotall returnrevision: 0for every pane, including actively-changing ones. Treat it as advisory / future-proofing only — never as a load-bearing change detector (Collie's prompt-select race guard re-derives the menu from content for exactly this reason).
events.subscribe {subscriptions: [{type, pane_id?}]} keeps the connection open and streams
events. Empty subscriptions: [] → ack only, no events ever arrive. The ack and the event frames
are shaped differently — worth calling out explicitly:
- Ack:
{"id":"<id>","result":{"type":"subscription_started"}}. - Event:
{"event":"<snake_case>","data":{...}}. Note the split: subscriptiontypevalues are dot-form (pane.agent_status_changed), but theeventfield on each streamed line is snake_case (pane_agent_status_changed). Real example line:{"data":{"pane_id":"w6:p3","type":"pane_agent_detected","workspace_id":"w6"},"event":"pane_agent_detected"}.
The full event catalog (subscription type values), 0.7.2 additions marked *:
workspace.created workspace.updated workspace.renamed workspace.closed workspace.focused workspace.moved *
worktree.created worktree.opened worktree.removed
tab.created tab.closed tab.focused tab.renamed tab.moved *
pane.created pane.closed pane.focused pane.moved pane.exited
pane.agent_detected pane.output_matched pane.agent_status_changed
layout.updated * pane.scroll_changed *
* = new to the catalog in 0.7.2 (workspace.moved, tab.moved, layout.updated,
pane.scroll_changed); workspace.updated and pane.focused were already listed but are called
out here too since they're easy to miss in the block above.
- Scoping, verified:
pane.agent_status_changed,pane.scroll_changed, andpane.output_matchedrequirepane_idin the subscription (omit it →invalid_request: missing field `pane_id`). Everything else is global — subscribe with just{type}. layout.updated(global) payload is a fullPaneLayoutSnapshot:{workspace_id, tab_id, zoomed, area, focused_pane_id, panes:[{pane_id,focused,rect}], splits:[{id,direction,ratio,rect}]}— the same shape assession.snapshot'slayouts[].pane.scroll_changed(pane-scoped) payload:{pane_id, workspace_id, scroll}(scrollshape as in "Object shapes" above).- Rich payloads:
pane_created/workspace_createdcarry the full pane/workspace record, not just ids.pane_exitedcarries{pane_id, workspace_id}.pane_agent_detectedcarries{pane_id, workspace_id, agent?}and can fire in herd-wide bursts on re-detection — consumers should debounce it.
Collie now polls session.snapshot (above) as the source of truth, and additionally holds a
long-lived events.subscribe stream — global lifecycle events plus a per-agent-pane
pane.agent_status_changed subscription, resubscribed whenever the agent-pane set changes —
purely to poke the poller: an event triggers an immediate debounced re-poll, it never updates
state by itself. While the stream is healthy, interval polling relaxes to COLLIE_POLL_IDLE_MS
(default 12000 ms, min 1000 ms); when the stream is down or reconnecting, it drops back to the
fast COLLIE_POLL_MS cadence. Events accelerate; the snapshot stays authoritative — a missed
event costs one interval, never correctness.
Also visible in the 0.7.2 schema but unused by Collie: events.wait, pane.send_input,
agent.list, pane.wait_for_output — run herdr api schema for the full ~80-method catalog.
{ "version":"0.7.2", "protocol":16, "workspaces":[ /* same record shape as workspace.list → workspaces[] */ ], "tabs": [ /* same record shape as tab.list → tabs[] */ ], "panes": [ /* same record shape as pane.list → panes[] */ ], "agents": [ /* precomputed subset of panes[] that carry an agent */ ], "layouts": [ /* per-tab PaneLayoutSnapshot, see layout.updated below */ ], "focused_workspace_id":"w0…", "focused_tab_id":"w0…:t1", "focused_pane_id":"w0…:p1" } // focused_* are string | null