JSON IPC¶
roostctl and Claude hooks drive the running Roost UI through a small
newline-delimited JSON protocol over a Unix-domain stream socket. The protocol
is local-only — there is no network deployment.
The UI binary (Swift Roost.app on Mac, roost-linux gtk4-rs binary on
Linux) is the IPC server. roostctl is the only first-party client; the
contract here is what any other automation should implement.
The socket path is the bundle profile's socket_path (see
paths.md):
- Mac (Swift
Roost.app):~/Library/Caches/Roost/roost.sock - GTK dev mode on Mac:
~/Library/Caches/Roost-gtk/roost.sock - Linux (XDG):
$XDG_RUNTIME_DIR/roost/roost.sock - Linux (else):
/tmp/roost-<uid>/roost.sock
Wire format¶
- Framing: newline-delimited JSON. One JSON object per line.
Max line length: 16 MiB. Lines longer than that are rejected with
frame-too-large. Embedded\ninside JSON strings is the encoder's responsibility (serde_jsonandJSONEncoderboth handle this correctly). - Request envelope:
{"id": "<string>", "op": "<dotted-name>", "params": {...}}. Theidis a string-wrapped 64-bit integer, because JSON numbers lose precision past 2^53; the legacy proto schema usedint64for tab/project ids and we preserve that range. Rust uses#[serde(with = "string_int64")]; Swift'sCodableuses a custom encoder that emitsString(describing: int64). - Response envelope (success):
{"id": "<string>", "ok": true, "result": {...}}. - Response envelope (error):
{"id": "<string>", "ok": false, "error": {"code": "<kebab>", "message": "<string>"}}. - Event envelope (server-push, unsolicited, only sent after
events.subscribe):{"event": "<dotted-name>", "data": {...}}— noid, no response expected. - Bytes payloads (e.g.
tab.write.data, and any future binary field): base64-encoded strings using the standard alphabet, no padding stripping. Tested for binary fidelity (0x00..0xffround-trip) in both directions. - Unknown fields: strict on the server side (rejected with
unknown-fielderror). Permissive on the client side (clients ignore unknown fields so the server can add fields without breaking older clients). Swift'sCodableis permissive by default and the client-side request encoders match that policy unchanged. On Rust,serdeis permissive by default — server-side request structs inroost-ipccarry#[serde(deny_unknown_fields)]to opt in to the strict server policy; client-side response structs do not, matching the client-side permissive policy. - Concurrency: the server is single-actor — every request is
dispatched onto the UI's main thread (Swift
@MainActor; gtk4 glib main loop). Responses are delivered in completion order, which is not guaranteed to match request order. Clients correlate byid. - Schema drift mitigation:
tests/ipc-vectors/*.jsonis a directory of canonical message exemplars (one file per op/event). Bothcargo test -p roost-ipc(Rust) and Swift'sXCTesttarget load these vectors and assert decode → re-encode → byte-equal. - Errors: stable kebab-case codes. Current set:
unknown-op,unknown-field,missing-param,invalid-param,parse-error,frame-too-large,duplicate-id,not-found,internal. Clients should treat unknown codes as fatal for the request and surfacemessageto the user.
Shared types¶
{
"Tab": {
"id": "<string-int64>",
"project_id": "<string-int64>",
"title": "<string>",
"cwd": "<string>",
"state": "<TabState>",
"has_notification": "<bool>",
"is_active": "<bool>",
"user_titled": "<bool>",
"position": "<int32>",
"created_at": "<int64-unix-seconds>",
"last_active": "<int64-unix-seconds>",
"hook_active": "<bool>"
},
"Project": {
"id": "<string-int64>",
"name": "<string>",
"cwd": "<string>",
"position": "<int32>",
"created_at": "<int64-unix-seconds>",
"tabs": ["<Tab>"]
}
}
TabState is a JSON string with values: "none", "running",
"needs_input", "idle". The legacy TAB_STATE_UNSPECIFIED is not
exposed — the server always picks a concrete state.
Operations¶
Operation names use dotted lowercase. params is omitted when an op
takes no parameters, but the field is permitted as {}.
identify¶
Returns the running UI's identity and active selection.
Request:
params.client_name and params.client_version are optional and are
logged by the server for debugging. Empty/missing is permitted.
Response:
{"id": "1", "ok": true, "result": {
"socket_path": "/Users/.../Library/Caches/Roost/roost.sock",
"pid": 1234,
"active_project_id": "1",
"active_tab_id": "3",
"app_label": "Roost",
"app_id": "ai.stridelabs.Roost",
"ui_version": "0.7.0",
"protocol_version": 1
}}
tab.open¶
Open a new tab in a project. If project_id is "0" and no projects
exist, the server creates a default project and opens the tab inside
it.
Request:
{"id": "2", "op": "tab.open", "params": {
"project_id": "1",
"cwd": "",
"argv": ["/bin/zsh"],
"cols": 120,
"rows": 30,
"title": ""
}}
argv empty means [$SHELL]. cwd empty means use the project's
default cwd. title empty means derive from cwd. There is
deliberately no opaque command string — callers wanting shell
word-splitting must pass ["sh", "-c", "..."] explicitly. This argv is
reachable from the CLI as roostctl tab open -- <cmd…> (see
cli.md).
Response: {"tab": <Tab>}.
tab.close¶
Close a tab; the PTY child is SIGHUP'd and reaped.
Request: {"params": {"tab_id": "3"}}. Response: {}.
tab.list¶
Snapshot of the workspace. Same shape as the legacy
ListTabsResponse.
Response: {"projects": [<Project>, ...]}.
tab.write¶
Headless write into a tab's PTY. data is base64-encoded raw bytes.
Request:
data decodes verbatim into the PTY master fd. Binary-clean (the
test suite round-trips 0x00..0xff). Errors not-found if the tab
has no live PTY.
Response: {}.
tab.resize¶
Headless resize of a tab's PTY (issues TIOCSWINSZ, which fires
SIGWINCH to the child group).
Request: {"params": {"tab_id": "3", "cols": 100, "rows": 24}}.
Response: {}.
tab.dump¶
Read the tab's live terminal viewport as text — the determinism
backbone for automated tests (assert on exact content instead of
OCR/pixel-matching a screenshot). Both UIs walk libghostty-vt's render
state on the main thread. Viewport only for now (scrollback is a planned
follow-up, so no scrollback param is accepted yet).
Request: {"params": {"tab_id": "3"}}.
Response:
{"cols": 120, "rows": 30,
"cursor": {"row": 1, "col": 14, "visible": true},
"rows_text": ["/tmp $ echo hi", "hi", "/tmp $", ""]}
rows_text has one entry per visible row, trailing blanks trimmed (a
blank cell renders as a space so columns line up). cursor is omitted
when the cursor is off-viewport. Response is permissive, so per-cell
color / scrollback fields can be added forward-compatibly. CLI:
roostctl tab dump --tab N (plain rows) / --json (full result).
tab.dump_resolved¶
Companion to tab.dump — a richer read of the same viewport, but each
cell carries the post-resolver fg/bg the production paint path computes
(including the theme's bold-color accent). Ungated; useful both for
debugging "why is this row gray" and as the resolver-walk regression
op for #142.
Request: {"params": {"tab_id": "3"}}.
Response (truncated):
{"cols": 80, "rows": 24,
"cells": [
{"row": 0, "col": 0, "text": "h", "fg": "#ffffff", "bg": "#1c1c1c",
"has_explicit_bg": false, "bold": true, "italic": false, "inverse": false},
{"row": 0, "col": 1, "text": "i", "fg": "#ffffff", "bg": "#1c1c1c",
"has_explicit_bg": false, "bold": true, "italic": false, "inverse": false}
]}
fg / bg are #RRGGBB strings (lowercase). has_explicit_bg
distinguishes a default-bg cell (false) from an SGR-bg cell (true) so
a test can pin paint behavior without reasoning about the canvas
fallback. text is " " for blank cells.
tab.feed_pty_bytes (test-only — gated)¶
Requires ROOST_TEST_MODE=1 set in the UI's launch environment.
Without it the server returns not-enabled. Injects raw bytes into a
tab's PTY-output drain as if the supervisor had emitted them; the OSC
scanner + libghostty + the input-reply path process them identically
to real shell output. No shadow drain — same channel the real
TabSession writes to. See
docs/development/test-automation.md §5.4.
Request:
data is base64-encoded raw bytes. Response: {}.
tab.capture_pty_input (test-only — gated)¶
Requires ROOST_TEST_MODE=1 at UI launch. Returns (and by default
drains) the bytes the UI has queued onto this tab's PTY-input channel
since the last drain — keystrokes, paste payloads, OSC-reply
synthesised replies. Combined with tab.feed_pty_bytes this lets a
test exercise the full OSC reply round trip end-to-end.
Request: {"params": {"tab_id": "3", "drain": true}}. drain
defaults to false (peek). Response:
project.create¶
Request: {"params": {"name": "", "cwd": "/tmp"}}. name empty means
the server picks "Untitled <n>".
Response: {"project": <Project>} — tabs is empty.
project.rename¶
Request: {"params": {"project_id": "1", "name": "Roost"}}. Response: {}.
project.delete¶
Cascades; tabs in the project are closed and their PTYs reaped before
the project is dropped. Subscribers see tab.closed for each child
tab followed by project.deleted.
Request: {"params": {"project_id": "1"}}. Response: {}.
tab.reorder¶
Request:
Order is leftmost first. Ids not belonging to project_id are rejected
with invalid-param. Tabs in the project not listed keep their
relative order after the listed ones.
Response: {}.
project.reorder¶
Request: {"params": {"project_ids": ["2", "1", "3"]}}. Order is
topmost first. Same partial-order rules as tab.reorder. Response:
{}.
tab.focus¶
Sets the active (project, tab) selection.
Request: {"params": {"tab_id": "3"}}. Response:
{"previous_project_id": "1", "previous_tab_id": "2"}.
tab.set_title¶
Manual rename. Sets Tab.user_titled = true so subsequent OSC 0/1/2
sequences from the shell do not overwrite it.
Request: {"params": {"tab_id": "3", "title": "build"}}. Response: {}.
tab.set_state¶
Request: {"params": {"tab_id": "3", "state": "running"}}. Response: {}.
tab.clear_notification¶
Clears Tab.has_notification and emits the corresponding
tab.notification event with has_pending = false.
Request: {"params": {"tab_id": "3"}}. Response: {}.
tab.set_hook_active¶
Marks the tab as owned by a hook session (e.g. Claude Code). While hook-active, raw OSC 9/777 from the shell is suppressed — only the hook drives notifications.
Request: {"params": {"tab_id": "3", "active": true}}. Response: {}.
notification.create¶
Fire a system notification for a tab.
Request:
Response: {}.
app.screenshot¶
Render the running UI's whole window (sidebar + tab bar + active
terminal) to a PNG, in-process — the UI re-draws its own view tree
rather than capturing the screen, so it needs no screen-recording
permission and works even when the window is unfocused, occluded, or
offscreen. Backs roostctl screenshot.
Request:
scale is the pixel multiplier — 1 (default) renders at logical
window size, 2 super-samples. Values outside 1..=2 are rejected
with invalid-param.
Response:
png is the PNG bytes base64-encoded (see Bytes payloads above);
width/height are the pixel dimensions actually rendered
(== logical size × scale). The response rides the same 16 MiB frame
ceiling as every other op — a normal window PNG is well under it.
Errors: internal when there is no window to capture, the window is
minimized (Mac) or not yet realized (Linux), or PNG encoding fails;
invalid-param for an out-of-range scale.
Command palette (palette.*)¶
Drive the command-palette overlay — open it, read its rows, filter,
activate a row, dismiss. UI-only: routed to the UI like app.screenshot,
not the workspace. A command row's id is its KeybindAction id, so
activating a row runs the same dispatch its hotkey would; activating a
sub-frame row (e.g. select_theme) drills in. Backs roostctl palette.
All five ops reply with the resulting palette state, so a driver needs no
follow-up palette.state:
{"open": true, "frame": "commands", "query": "tab", "selection": 2,
"items": [{"id": "new_tab", "title": "New Tab"},
{"id": "select_theme", "title": "Select Theme…"}]}
open is false when no palette is up (the other fields are then
empty/default). When open, frame is the current frame id (commands |
launcher | themes | notifications), and items are the filtered
rows in display order (subtitle present on rows that have one).
| Op | Request params | Notes |
|---|---|---|
palette.open |
{"kind": "commands"} |
kind: ""/commands → command palette; launcher → custom-command launcher; custom → the script-backed provider palette. Other values → invalid-param. |
palette.state |
{} |
Read the current state. |
palette.query |
{"query": "theme"} |
Set the current frame's filter (resets selection to the top match). |
palette.activate |
{"id": "new_tab"} |
Confirm the visible row with this id — runs its command or drills into its sub-frame. not-found if no palette is open or no row matches. |
palette.dismiss |
{} |
Close any open palette. |
palette.present |
{"title": "Open shed", "items": [{"id": "web", "title": "shed: web"}]} |
Open the palette on a caller-supplied list and block until the user picks a row or dismisses. Replies {"selected_id"?, "dismissed"} — selected_id is omitted on dismissal. invalid-param if items is empty. The programmatic twin of the command palette; items are {id, title, subtitle?} (the actionable flag a provider can set is not carried here — present rows are always selectable in v1). v1 limitation: if the client disconnects while blocked, the palette stays open until the user dismisses it (no server-side cancellation yet). |
Selection + clipboard test ops (selection.* / clipboard.*)¶
| Op | Params | Effect |
|---|---|---|
selection.set |
{"tab_id": "1", "anchor": {"col": 3, "row": 0}, "cursor": {"col": 17, "row": 0}} |
Anchor a selection on the tab's terminal at viewport (col, row). The UI converts to libghostty's PointTag::Screen internally so the selection survives scrolling — same flow as mouseDown + mouseDragged. not-found if the tab has no live terminal. |
selection.clear |
{"tab_id": "1"} |
Drop the active selection (no-op if none). |
selection.dump |
{"tab_id": "1"} |
Read back the selection. Response: {"text"?: "...", "anchor_visible": bool, "cursor_visible": bool}. text is omitted when no selection is active or when all selection rows have scrolled out of the viewport (the v1 partial-copy limitation). |
clipboard.dump |
{"target": "system" \| "selection"} |
Read the host pasteboard. Response: {"text"?: "..."}. system is the ⌘V / Ctrl+V target; selection is the named per-app pasteboard on Mac / X11 PRIMARY on Linux. Unknown targets → invalid-param. |
clipboard.write |
{"target": "...", "text": "..."} |
Test-only pasteboard seeding (lets a roosttest case set a known value before asserting paste behavior). Not gated: any process on the host can already write the OS clipboard. |
roostctl does not surface these yet — they exist for end-to-end test
coverage (tools/roosttest/) and as a stable surface a future scriptable
selection-driving feature (AI agent highlighting a region for the user
to confirm) could build on. Each op routes through the UI seam
(UiRequest::Selection* / UiRequest::Clipboard* on Linux, the
UiBridge protocol on Mac), not the workspace — pasteboard + selection
state live on the UI side.
events.subscribe¶
Opt-in to the event stream. After the response, the server pushes
{"event": ..., "data": ...} envelopes on the same connection until
the connection closes.
Request: {"params": {"tab_id_filter": "0"}}. A non-zero
tab_id_filter restricts the stream to events for that tab.
M0 status: stubbed. The server replies {"ok": true, "result":
{}} and never sends event envelopes on the connection. This is
intentional — roostctl does not need events for any current
subcommand, and clients that do want events will surface as
follow-ups against a working stub.
Response: {}.
Events¶
Server-push only. Each is a line of the form {"event": "<name>", "data":
{...}}. The set below is the exhaustive list; no other event names are
emitted.
tab.opened—{"tab": <Tab>}.tab.closed—{"tab_id": "<id>"}.tab.state_changed—{"tab_id": "<id>", "state": "<TabState>"}.tab.title_changed—{"tab_id": "<id>", "title": "<string>"}.tab.cwd_changed—{"tab_id": "<id>", "cwd": "<string>"}. Note: when an OSC 7 (ortab.set_cwd-equivalent) lands on a tab whoseuser_titledis false, the workspace also re-derives the title from the basename of the new cwd. Subscribers will see atab.cwd_changedimmediately followed by atab.title_changed(in that order, cause-then-effect) for that single op — treat them as a pair, not as one-event-per-op. On shells with the shipped integration, a furthertab.title_changedarrives a prompt cycle later (OSC 0 → tilde-abbreviated full path).tab.notification—{"tab_id": "<id>", "has_pending": <bool>}.project.created—{"project": <Project>}(tabs empty).project.renamed—{"project_id": "<id>", "name": "<string>"}.project.deleted—{"project_id": "<id>"}.active.changed—{"project_id": "<id>", "tab_id": "<id>"}(either may be"0").hook_active.changed—{"tab_id": "<id>", "active": <bool>}.notification.fired—{"tab_id": "<id>", "title": "<string>", "body": "<string>"}. Mirrors the legacy proto'sNotificationEvent; useful for tools that mirror notifications elsewhere.
Dropped vs. the legacy proto¶
These RPCs/messages were intentionally dropped — the new architecture makes them unnecessary:
StreamPty(PtyClientMessage,PtyServerMessage, all variants). The UI owns the PTY; nothing crosses the wire.ReportOsc. OSC sequences are parsed in the UI; the UI updates its own state directly. There is nobody to round-trip to.WatchEvents(legacy event stream RPC) is replaced by theevents.subscribeop + push envelopes on the same connection.
Schema-only fields that survive but rename:
- Proto
TabStateenum → JSON string. Mapping:TAB_STATE_NONE → "none",TAB_STATE_RUNNING → "running",TAB_STATE_NEEDS_INPUT → "needs_input",TAB_STATE_IDLE → "idle".TAB_STATE_UNSPECIFIEDis omitted; the server never returns it.
Versioning¶
identify.protocol_version is the integer schema version. M0 ships
version 1. Additive changes (new optional fields, new ops, new
events) do not bump the version. Breaking changes coordinate a major
version bump and updated clients.