Skip to content

roostctl

Shell-integration CLI for the running Roost UI. Talks JSON over a Unix-domain socket directly to the UI process — no daemon by default, except a fresh Roost-Iced install, which starts its local tabs on a roost-session it manages. The other exception is opt-in: roostctl session starts, stops, and inspects a headless roost-session daemon (see session subcommands below). Intended to be invoked from inside a Roost tab (typically by an agent hook) but works from any shell that can reach the socket. See docs/reference/ipc.md for the wire format.

Crate: crates/roost-cli (binary roostctl).

Usage

roostctl [--socket <PATH>] [--target <mac|linux|iced|session>] [--json] <COMMAND>
Command Purpose
notify Fire a notification on a tab
set-title Rename a tab (locks it from OSC overwrites)
identify Print the running UI's identity (socket, PID, active tab, version)
tab focus Focus a tab (raises window, switches project, selects tab)
tab list List every tab grouped by project
tab set-state Set the per-tab agent state
tab clear-notification Clear a tab's pending-attention flag
wait Block until a tab reaches a state, shows a string, or closes
events Print the event stream, one JSON line per event
tab open / close / send / resize / reorder Tab lifecycle + I/O
tab report Report an agent-hook event straight to tab.agent_report
tab prompt Submit a prompt to a tab's agent and wait for the turn it starts
project list / create / ensure / rename / delete / reorder Project lifecycle
open Find-or-create a project by name, then open a tab in it — one call, atomic on the server
agent ensure / set / install / uninstall / status Wire Roost's hook entries into the supported agents' own configs
agent-hook <agent> Internal: the one hook entrypoint every supported agent invokes
claude install Alias of agent install claude
claude-hook Internal: invoked by Claude on each hook event (kept for settings files an earlier Roost wrote)
doctor Read-only diagnosis of the Roost integration (target, socket, shell, tab, agent hooks)
session start / stop / status Start, stop, or inspect the headless roost-session daemon
rpc <op> [params] Call any IPC op directly by name, bypassing every named verb
skill Print the agent skill (skills/roost/SKILL.md), byte for byte as the plugin installs it
schema Print the wire's JSON Schema bundle (docs/reference/api/roost-ipc.schema.json), byte for byte

--socket overrides ROOST_SOCKET; one of the two must resolve to the running UI's socket. A session is not a UI: session start|stop|status address the session profile's own socket directly and ignore --target / --socket / ROOST_BUNDLE_PROFILE entirely (see session subcommands). Any other op reaches a running session only explicitly — --target session or --socket <path> (#475) — never by ROOST_BUNDLE_PROFILE (session is refused there like any other unrecognized value) or auto-detect (a session is never a candidate); from inside a session tab, only the verbs that stay on it do. An op the session does not serve fails with the server's own error for that op — unknown-op for host.* and agent.set_hooks, internal: no UI attached for app.* window/UI ops (app.activate alone answers {} and does nothing) — not a session-specific one; see ipc.md for the wire-level contract and identify / session.identify for what each socket serves.

Inside a session tab

Under local-backend = session, a fresh install's default, a local tab's shell runs on the roost-session, so its ROOST_SOCKET names that session rather than the window. When ROOST_SOCKET picked the socket and neither --socket nor --target was given, roostctl asks each running window which local session it owns and sends the verb to the window that owns this one. It asks with one identify per window, each allowed 500 ms; a window that hasn't answered by then owns nothing. So palette, screenshot, host, agent set, tab send-file, tab focus, tab open and open (which select the new tab in the window), rpc, identify and doctor behave there exactly as they do from a plain terminal outside Roost. Tab ids need no translating: a bare id on the window's socket already means the session's.

Five verbs stay on the session:

  • agent-hook and claude-hook, which have to report with the window closed;
  • events, wait and tab prompt, which read the session's own event stream.

session start|stop|status never read ROOST_SOCKET at all (see session subcommands). Everything else is unchanged:

  • --socket or --target given: the socket named is the socket dialled, so --socket "$ROOST_SOCKET" reaches the session itself.
  • No running window owns the session (it is closed, or runs its tabs in-process): the verb reaches the session, as before.
  • A tab of an in-process window, or of a remote host: its ROOST_SOCKET is not this machine's local session, so no window is asked anything. Reaching a remote tab's window is #560.
  • Two windows own the session (a development setup): roostctl exits 2 usage, naming both sockets. Pass --socket.

JSON output

--json is a global flag: every command accepts it, before or after the subcommand (roostctl --json tab list and roostctl tab list --json are the same call). With it, a command prints exactly one JSON document on stdout:

Command --json prints
A command backed by one IPC op (identify, tab open, tab dump, tab send-file, project create, render-stats, palette …, host add/connect/disconnect, bare agent set, …) That op's result object, verbatim
A command that prints nothing without the flag (notify, set-title, tab send, tab close, tab focus, tab resize, tab set-state, tab clear-notification, tab reorder, project rename/delete/reorder, host remove) The op's result object — usually {}
tab list, project list The tab.list result
host list, host status, agent status, agent ensure, doctor The same JSON these printed with their own --json before it became global
agent install / uninstall / set --local The outcome agent ensure --json prints
wait {"tab_id", "satisfied": {"state", "text", "gone"}, "after_ms"} once the condition holds — see wait
screenshot {"out": "<path>", "bytes": N} — and it needs --out, since the PNG and the JSON would otherwise share stdout
session start {"outcome", "socket", "identity", "launcher_reported_pid"}, outcome being started or already-running
session stop {"socket", "session_id", "reap"} — session_id and reap are null when nothing was running
session status {"socket", "identity", "projects", "tabs"}
agent-hook, claude-hook Nothing different: they ignore the flag and always answer {} and exit 0, because a decision hook must never be blocked by a CLI error
project ensure {"project", "created"} — always, --json or not; see project ensure
open {"project", "tab", "created"} — always, --json or not: an agent verb, not a human-typed one; see open
rpc Not affected by the flag at all — see rpc below
events Not affected either: always one JSON line per event — see events
skill {"topic": "roost", "format": "markdown", "content": "…"} — always the same skill --json or not; see skill
schema Not affected by the flag at all: the printed file already is JSON — see schema

Without --json, every command prints what it always has. An error is never written to stdout either way — see Exit codes.

Which tab a command acts on

A command that changes a tab — notify, set-title, tab set-state, tab clear-notification, tab close, tab send, tab resize, tab focus, tab report, tab prompt — acts on --tab, or else on $ROOST_TAB_ID. With neither it refuses, before it dials anything, and exits 2:

roostctl: usage: no --tab and ROOST_TAB_ID is unset; refusing to guess the active tab for a command that changes it — `roostctl tab list` shows ids

Every shell inside a Roost tab has ROOST_TAB_ID set to its own tab, so the bare forms in the examples below work exactly as written there. Outside a tab — a CI runner, a second terminal, an agent driving Roost from elsewhere — pass --tab; the UI's active tab is whatever a person last clicked, which is no answer for a command that writes to a tab.

The read-only tab dump and wait still fall back to the UI's active tab when ROOST_TAB_ID is unset too. tab send-file has always required --tab. events --tab is a filter, not a target, so it reads neither: without the flag every event prints. A ROOST_TAB_ID that is not a tab id is refused the same way (exit 2).

Only tab focus, tab dump and tab send-file take a host tab's h<host>.<id>. Every other --tab names a local tab and refuses a host ref, canonical or not, before it dials anything (exit 2):

roostctl: usage: tab close acts on a local tab, so it cannot take the host tab h1.7; pass a bare tab id

Usage: roostctl tab close [OPTIONS]

For more information, try '--help'.

Where roostctl lives

roostctl ships next to each UI, but the two platforms put it in different places — only one is on PATH:

Platform Path On PATH?
Linux (.deb) /usr/bin/roostctl ✅ yes
macOS, Roost.app (.dmg) Roost.app/Contents/Resources/bin/roostctl (inside the bundle) ❌ no — a Finder-launched app gets a minimal PATH
macOS, Roost-Iced.app (.dmg) Roost-Iced.app/Contents/Resources/bin/roostctl (inside the bundle) ❌ no — a Finder-launched app gets a minimal PATH

For your own shell on macOS, symlink it onto PATH once (ln -s /Applications/Roost.app/Contents/Resources/bin/roostctl /usr/local/bin/roostctl, or the equivalent Roost-Iced.app path if that is the build you drive agents through). Provider scripts don't need to — Roost sets ROOST_ROOSTCTL to the absolute path of its own roostctl when it runs them, so "${ROOST_ROOSTCTL:-roostctl}" is portable across both platforms. See Extending Roost.

Running both apps at once, roostctl can't auto-detect which one you mean and exits 1 with ambiguous-target (exit codes below); pass --target mac or --target iced (or set ROOST_SOCKET to the specific socket) to pick one.

notify

roostctl notify --title "Build done" --body "tests pass"   # inside a Roost tab
roostctl notify --tab 3 --title "From CI" --body "deploy ready"

The first form relies on ROOST_TAB_ID, which only a shell inside a Roost tab has; anywhere else pass --tab (why).

Flag Type Default Description
--title string required Notification title
--body string empty Notification body
--tab int $ROOST_TAB_ID Target tab id; exits 2 when neither is set

set-title

Set a tab's display title. Persists across restarts and locks the tab against subsequent OSC 1/2 escapes from the shell.

roostctl set-title --title "build-watcher"   # inside a Roost tab
roostctl set-title --title "deploy" --tab 3

The first form relies on ROOST_TAB_ID, which only a shell inside a Roost tab has; anywhere else pass --tab (why).

Flag Type Default Description
--title string required New tab title
--tab int $ROOST_TAB_ID Target tab id; exits 2 when neither is set

identify

roostctl identify
socket=/Users/charliek/Library/Caches/Roost/roost.sock
pid=14138
active_project=1
active_tab=5
ui_version=0.1.0
proto_version=1
app_id=ai.stridelabs.Roost

Prints key=value lines; --json prints the full identify result instead. Useful for verifying the socket is reachable and the env vars are wired correctly.

tab focus

roostctl tab focus               # focus the calling shell's tab (inside a Roost tab)
roostctl tab focus --tab 7
roostctl tab focus --tab h3.7    # a tab on connected host 3 (host sessions)

The bare form relies on ROOST_TAB_ID, which only a shell inside a Roost tab has; anywhere else pass --tab (why).

Raises the window, switches the active project, selects the tab. Used as the click-through target for desktop banners.

--tab also accepts the host-qualified h<host>.<id> spelling (see Host Sessions) on a UI target — selects that host's tab in the sidebar rather than reporting a local previous selection. Against a bare session socket the qualified form is invalid-param: a session has no UI to hold a selection.

tab list

roostctl tab list
roostctl tab list --json

Default output is a human-readable tree; --json prints the raw response. Each tab carries id, title, agent_state, has_notification, and is_active.

tab set-state

roostctl tab set-state --state running   # inside a Roost tab
roostctl tab set-state --tab 3 --state idle

The first form relies on ROOST_TAB_ID, which only a shell inside a Roost tab has; anywhere else pass --tab (why).

Flag Type Default Description
--state string required One of none, running, needs_input, idle
--tab int $ROOST_TAB_ID Target tab id; exits 2 when neither is set

tab report

roostctl tab report --tab 3 --source my-agent --session-id s-1 --claim \
  --lifecycle working
roostctl tab report --tab 3 --source my-agent --session-id s-1 --preserve \
  --attention set --title "Needs input" --body "waiting on a confirm"
roostctl tab report --tab 3 --source my-agent --session-id s-1 --release

A thin wrapper over the tab.agent_report op — flags map one-to-one onto its params (ipc.md). For an agent with no adapter yet, or a script driving the op by hand; a wired adapter reports through agent-hook/claude-hook instead. Needs --tab or ROOST_TAB_ID (why).

Flag Type Default Description
--source string required Ownership identity, half one. Refused when it names a Roost agent adapter's own source, or manual/legacy
--session-id string required Ownership identity, half two. Pass "" for a source with no session concept
--claim / --preserve / --release flag — Exactly one is required: take ownership, report without changing it, or give it up
--lifecycle string unchanged One of inactive, working, waiting, finished, failed
--if string, repeatable unconditional Guard: apply --lifecycle / --attention set only if the tab's current lifecycle is one of these
--attention string preserve One of set, clear, preserve. set requires non-empty --title and --body
--severity string info One of info, warn, error
--title / --body / --detail string "" The attention banner's title/body, and a free-form reason recorded on the owner
--metadata KEY=VALUE, repeatable — Open extension data. An empty key, a value with no =, or a repeated key is usage

--json prints the tab.agent_report reply verbatim (accepted, tab). Otherwise: reported: tab N <lifecycle> on accepted: true (the returned lifecycle — --release forces inactive even with no --lifecycle given); not accepted: tab N is owned by <source>/<session_id>, or not accepted: tab N has no owner, on accepted: false. Exit 0 either way — the op succeeded and the ownership answer is data for the caller. A server refusal (a bad param, an unknown tab) exits 1 with the server's own code, same as every other verb.

tab prompt

Submit a prompt to the agent running in a tab and wait for the turn it starts. The race-free form of tab send followed by wait, and the one verb that needs no if around the running wait.

roostctl tab prompt --tab 3 --timeout 600 'Summarize the failing tests.'
roostctl tab prompt --tab 3 --no-timeout --until needs_input --json 'Fix it.'

The sequence. No new op — the verb is two tab.writes and the event stream, in the one order that closes the race between them:

  1. Subscribe to the stream and fence it with a tab.list snapshot, exactly as wait does — before anything is written.
  2. tab.write the text, on the request connection that subscription checked the identity of.
  3. tab.write \r on that same connection, as a separate write, so an input box that reads one write as a paste does not swallow the Enter into the text.
  4. The activity gate: within --activity-timeout, or whatever --timeout has left, whichever is sooner, the tab must reach running. Otherwise exit 4 stalled.
  5. The settled wait: until the tab's state is one of --until. Then exit 0.

Both writes go on the subscribed connection so the prompt and the wait cannot straddle a restart, and the stream is fenced first so a turn that starts and ends quickly cannot slip into the gap between them — which is exactly what a tab send and a separate wait --state running can miss.

Flag Type Default Description
<text> positional required The prompt. Written as-is; Enter follows as a second write
--tab bare id $ROOST_TAB_ID Target tab; exits 2 with neither (why). A host tab (h<host>.<id>) is refused
--activity-timeout float 5.0 How long the tab has to reach running after the prompt. Never outlasts --timeout
--until string, repeatable idle, needs_input A state the turn may settle in (none/running/needs_input/idle)
--timeout float — Give up after this many seconds, every call to the socket included. Required unless --no-timeout: how long a turn takes is not something this verb can guess, so it has no default. 0, a negative, inf and nan are all usage (exit 2)
--no-timeout flag false Wait for as long as the turn takes. Cannot be combined with --timeout

Two caveats. The gate is temporal, not causal: it asks whether the tab reached running after the fence, not whether this prompt is what took it there, so an unrelated turn starting in the window satisfies it. And a tab that was already running when the stream was fenced has no start left to catch: the writes still go through (queueing a prompt behind the one running is the agent's business), the gate is skipped, the settled wait begins at once, and started_after_ms is null.

The stream is required. Against a server whose identify.ops lacks events.subscribe and that names no local_session_socket — the Swift Mac app today — the verb exits 1 unsupported and writes nothing. There is no polling fallback: a poll cannot see a turn that starts and ends between two tab.list calls, so it would report a stall that never happened. Use tab send and tab dump there.

Every call to the socket is bounded the way wait's are, and a stream lost after the writes follows wait's rules — resolved again once, and only against the same process. The writes are never repeated.

Exit When
0 The turn settled in one of --until
4 stalled The tab reached no running within --activity-timeout of the prompt. The text and the Enter were written, so the turn may yet start — read the tab with tab dump before sending anything again
4 timeout --timeout ran out, whether in the gate or in the settled wait. --timeout always wins over --activity-timeout and keeps its own code
2 usage No --tab and no ROOST_TAB_ID (checked first, before the budget), a host --tab, a missing or non-positive --timeout with no --no-timeout, or a non-positive --activity-timeout
1 unsupported This server serves no event stream
1 the server's own Either tab.write was refused — not-found for a tab with no live PTY, say — passed through verbatim
1 connection The stream was lost twice, the Roost serving the tab changed, or a call went unanswered for 30 s under --no-timeout

With --json, success prints:

{"tab_id": "3", "started_after_ms": 412, "settled": {"state": "idle"}, "after_ms": 38104}

Both durations are measured from the Enter write, not from the command's start. started_after_ms is null when the gate was skipped. Without --json the same facts print as one line: prompted: tab 3 started after 412ms, idle after 38104ms.

tab open / close / send / resize / reorder / dump

Tab lifecycle and I/O for automation. tab close, tab send and tab resize act on --tab or $ROOST_TAB_ID and exit 2 with neither; tab dump falls back to the UI's active tab (why). tab send needs an existing live PTY (a UI must have already attached); errors with NotFound otherwise. --bytes accepts Rust string-escape sequences (\n, \r, \x1b, …); pass --raw to disable escape decoding.

roostctl tab open --project-id 1 --cwd ~/projects/roost
roostctl tab open --project-id 1 -- htop                       # run a command in the tab
roostctl tab open --project-id 1 --hold -- make test           # keep the tab open after it exits
roostctl tab open --project-id 1 --after-tab 5 --focus -- vim   # next to tab 5, then focus it
roostctl tab open --project-id 1 --no-activate -- make test     # open it without selecting it
roostctl tab open --project-id 1 --cwd-from-tab 5 -- vim         # start where tab 5's cwd is
roostctl tab open --project-id 1 --here -- vim                   # start where $ROOST_TAB_ID's cwd is
roostctl tab close --tab 5
roostctl tab send --tab 5 --bytes 'ls -la\n'
roostctl tab resize --tab 5 --cols 120 --rows 40
roostctl tab reorder --project-id 1 --order 3,5,7
roostctl tab dump --tab 5          # the visible viewport as text
roostctl tab dump --tab 5 --json   # full result: dims + cursor + rows
roostctl tab dump --tab 5 --scrollback 200   # 200 rows of history, then the viewport

tab open prints the new tab id on stdout (so id=$(roostctl tab open …)); --json prints the tab.open result instead. A command can follow --; without one the tab opens the default shell. The command's working directory is --cwd (default: the project's cwd); a --cwd that is not a directory falls back the same way, to the project's cwd (else $HOME).

Flag Effect
-- <cmd…> Run this command in the tab. The tab closes when the command exits (hold=false) — standard terminal behavior.
--hold Keep the tab open after the command exits, dropping to an interactive shell (mirrors command = … hold=true). Only meaningful with a command.
--after-tab <id> Place the new tab immediately after that tab (same project) instead of at the end. Best-effort: if that tab is gone by the time the reorder lands, the new tab stays at the end.
--focus Focus (activate) the new tab after opening. Under local-backend = session the focus waits, on the UI socket, for the window to list the new tab rather than answering not-found — from inside a session tab too, which dials the window.
--no-activate Open the tab without selecting it: the active project and tab stay where they were (tab.open's activate: false). Without it, opening a tab selects it. Refused beside --focus (exit 2 usage). A server that predates the field — Roost.app through v0.0.20 among them — answers unknown-field, which is reported verbatim; nothing retries without the flag.
--cwd-from-tab <id> Start the new tab where that tab's working directory is, when the server can resolve one (tab.open's cwd_from_tab; see ipc.md). Omitted ⇒ the field is absent on the wire, not sent as null. A host tab (h<host>.<id>) is refused: cwd_from_tab never crosses a host. A server that predates the field answers unknown-field, same as --no-activate. Refused beside --here.
--here --cwd-from-tab $ROOST_TAB_ID: start where the calling tab already is. Only accepted with no explicit --socket/--target naming another target — $ROOST_TAB_ID is otherwise not necessarily this call's own id space — including from inside a session tab, which still dials the window that owns it. Anything else is exit 2 usage. Refused beside --cwd-from-tab.

These compose: --after-tab X --focus -- <cmd> is the "open a command in a tab right here and switch to it" primitive that providers and other scripts use. (--after-tab/--focus are CLI orchestration over tab.reorder / tab.focus; -- <cmd> fills the tab.open op's argv — see ipc.md.)

tab send needs no credential. A write needs none on a UI socket (--target mac|linux|iced) or a host session's own socket (reached with --target session or --socket <path>, e.g. one a roostctl session start daemon owns) alike: same-UID access to the socket is the whole boundary, and there is no authority layer above it to present anything to. There is deliberately no --lease flag and never was one worth adding back — argv leaks through shell history and ps, and there is nothing on this wire a flag like that could still mean.

tab dump reads the tab's live terminal viewport as text — the determinism backbone for tests: assert on exact content instead of matching pixels. Plain output is one line per visible row (trailing blanks trimmed); --json adds dimensions and cursor. Backed by the tab.dump IPC op — see ipc.md. Under local-backend = session a bare id (from --tab, ROOST_TAB_ID, or the active tab) is read from the session identify.local_session_socket names, as wait reads it, because the UI holds a terminal only for the tab it is showing; an h<host>.<id> still reads the UI's.

--scrollback <N> widens the read to N rows of history above the viewport. In plain mode the history rows print first, with no separator, so roostctl tab dump --scrollback 200 --tab 5 | grep … keeps working over the larger window rather than needing a new parse; --json carries them as scrollback_text beside a scrollback_rows count of everything that was available. The rows are anchored on the viewport that was dumped, so the last history row is always the one directly above the first visible row, even on a tab the user has scrolled up. Asking for more than exists — or more than the server's 10 000-row cap — is clamped, not refused: --scrollback 1000000 is a legitimate way to say "all of it".

Omitting the flag sends the identical request bytes roostctl always sent, so nothing changes against an older Roost. Using it against one does not degrade quietly, though: tab.dump's params are strict, so a UI or session that predates the field answers unknown-field and the whole request fails rather than returning a viewport-only result you might mistake for an empty history. Against a host session there is no capability list to consult: protocol 5 deleted session.identify.features, and the session integer now carries the whole contract — a session this Roost will talk to at all serves the field.

--tab on dump (and the tab.dump_resolved op, plus the test-only tab.capture_pty_input) accepts the same host-qualified h<host>.<id> spelling tab focus does, reading an attached host tab's client-side terminal — the UI's own copy, distinct from the session's own tab dump served over its socket. See Host Sessions.

tab send-file

roostctl tab send-file --tab h2.7 ~/Desktop/shot.png
roostctl tab send-file --tab h2.7 shot.png notes.pdf --json
roostctl tab send-file --tab 5 ~/Desktop/shot.png       # a local tab

Send local files to a tab. On a host tab each file is uploaded to that host and the host paths are pasted, so an agent running there can actually read them; on a local tab it pastes the escaped local paths. Exactly what dropping the files onto the tab does — same route, same gesture queue — backed by the tab.send_file op.

Linux/iced only. The Swift Roost.app does not serve this op and answers unknown-op; it has no host sessions to upload to, and its local drop route is unchanged. See the Swift fold-in note.

Flag Effect
--tab <id> Required. Bare engine id, or h<host>.<id> for a connected host's tab.
<path>… At least one. Canonicalized in roostctl's own working directory before sending.
--json Print the whole result — pasted, uploads, skipped — instead of just the pasted text.

--tab is required here, and unlike the other commands that change a tab it does not read ROOST_TAB_ID either: the id in a shell's environment is a local tab, which is never the host tab a caller sending files means, and guessing it would paste a remote-looking gesture into a local shell. Paths are canonicalized on this side because the shell you typed them in is the only cwd that can resolve a relative one — the op itself refuses relative paths for exactly that reason — so a path that is not there fails locally, naming it (cannot send shot.png: …), rather than after a round trip.

Default output is the pasted text on stdout — the host paths for a host tab, the escaped local paths for a local one. Files the gesture could not send (a directory, something unreadable, a file over the 10 MiB per-file cap) are reported in --json's skipped and do not stop the rest; a request where every path was skipped is an error naming each one.

It blocks, and prints nothing until it is done. The command returns when the paste has been queued in the tab, which for a 10 MiB file over a slow link is minutes. That is deliberate: there is no progress channel on this wire, and the pasted text is the only thing worth printing. The wait is bounded — 60 s plus 300 s per file, multiplied by ROOST_TEST_TIMEOUT_SCALE like every other budget — because IpcClient has no request timeout of its own and a wedged UI would otherwise hang the caller forever. On expiry it says so and warns that the paste may still land.

wait

Block until a tab reaches a condition, then exit 0 — the no-sleep synchronization primitive for scripts, tests and agents. At least one condition is required; when several are given, all must hold.

roostctl wait --tab 5 --state idle            # until the agent state is idle
roostctl wait --tab 5 --text 'BUILD OK'       # until the viewport contains a string
roostctl wait --tab 5 --gone                  # until the tab is closed
roostctl wait --tab 5 --state idle --no-timeout --json
Flag Type Default Description
--state string — Wait until the tab's agent state equals this (none/running/needs_input/idle)
--text string — Wait until the viewport (via tab.dump) contains this substring. Pick a needle from command output, not the echoed command
--gone flag false Wait until the tab no longer exists. Cannot be combined with --state or --text
--timeout float 5.0 Give up after this many seconds, every call to the socket included. 0 checks once: exit 0 if the condition already holds, 4 if not. A value that is not a finite number of seconds (inf, nan), or too long to set a deadline by, is usage (exit 2), never "forever"
--no-timeout flag false Wait for as long as it takes. Cannot be combined with --timeout (exit 2)
--interval-ms int 100 The poll interval where there is no stream, and how often --text re-reads the viewport where there is
--tab bare id $ROOST_TAB_ID, then the UI's active tab Target tab. A host tab (h<host>.<id>) is refused with usage (exit 2): the stream is the local workspace's

Where it listens. wait asks the UI's identify which socket owns the tabs, and follows that socket's event stream instead of polling:

identify says wait reads
local_session_socket is present (local-backend = session) The local session: both connections dial that socket
ops names events.subscribe (the iced UI, in-process) The UI socket itself
Neither — the Swift Mac app, or a Roost older than this The poll loop: tab.list (and tab.dump for --text) every --interval-ms, as wait always did

On a stream it subscribes on one connection, then — on a second — checks that identify.instance_id (or, on a session, session.identify.session_id) is the one the subscription acked, and only then takes a tab.list snapshot. Batches the snapshot already holds are discarded; a snapshot older than the subscription fences nothing and counts as a lost stream. --state and --gone are then decided by tab.state_changed and tab.closed as they arrive, with no tab.dump at all. No event carries a tab's output, so --text re-reads the viewport on every event for the tab and every --interval-ms. A bare --tab id means the same tab on the UI socket and on the session under local-backend = session, so the id (or the active tab) is resolved on the UI and used on the session unchanged.

When the stream goes away. A stream that closes without a label, skips a revision, or ends with stream.ended, or whose second connection drops — while the subscription is still being opened, too — is resolved again from identify once, after any local-backend switch in flight has settled. The wait carries on only if the new stream is the same process — the same instance_id or session_id its first subscription acked — as it is for a subscriber the server cut for falling behind. Anything else exits 1 connection: "the Roost serving tab N changed … tab ids do not carry across — re-resolve the tab and wait again". Tab ids belong to the process that minted them: a switch replays the tabs onto its destination under new ids, and a restart mints new ones, so tab N over there is some other tab or none, and --gone read off its snapshot would be wrong. stream.ended is a switch, so a wait across one normally ends that way. A second loss exits 1 connection too, and so does a resolve that fails — no Roost found, or a server that refuses the subscribe — with that failure's own code and message inside the connection one. A session that stops (session.stopping) exits 1 connection at once, with that reason. If the two connections of one subscription keep reaching different processes (the server restarting between them), the sequence is retried three times and then exits 1 connection.

Exits. 0 once the condition holds; 4 (timeout) if --timeout elapses first, including while a backend switch in flight holds the wait off (re-checked every --interval-ms, never past the deadline) and inside a call the socket has not answered — "timed out after 1s waiting for tab 5 (tab.dump did not answer)" — before plan 066 it exited 1, which a script could not tell from a failed connection. Under --timeout 0 and --no-timeout there is no deadline, so each call gets 30 s: a call left unanswered that long exits 1 connection ("tab.dump did not answer within 30s"), because a server that accepts a call and never answers it is a dead connection, not a condition still pending. 2 usage for a bad command line, including a --timeout that is not a finite number of seconds; 1 for everything in Exit codes.

With --json, success prints:

{"tab_id": "5", "satisfied": {"state": "idle", "text": null, "gone": null}, "after_ms": 812}

Each satisfied field is null unless its flag was given: state is the state the tab was seen in, text the needle that was found, gone is true. after_ms counts from the command's start, dialling included.

events

Print the event stream as a line protocol: one compact JSON document per line, flushed as it arrives, until the stream ends.

roostctl events                       # every event
roostctl events --tab 5               # only the events that name tab 5
roostctl events | jq -c 'select(.event == "tab.state_changed")'

Each line is one event envelope with the revision of the commit it rode in:

{"event":"tab.state_changed","data":{"tab_id":"5","state":"idle"},"revision":42}

Events of one commit share a revision and keep their order; a commit with no events prints nothing. The stream's last line is its terminal envelope, which carries no revision: {"event":"session.stopping","data":{"reason":"stop"}} from a session, or {"event":"stream.ended","data":{"reason":"backend-switch"}} from a UI whose local backend is switching.

Flag Default Description
--tab — Keep only the events that name this tab — by data.tab_id (for active.changed, the tab it made active), data.tab.id for tab.opened, or any of data.tab_ids for tabs.reordered — and drop the rest, event by event: a commit touching several tabs keeps just this tab's lines. Events that name no tab (project.*, projects.reordered, workspace.durability_changed) are dropped. The terminal envelope always prints. A bare id only; a host tab is refused with usage. Not read from ROOST_TAB_ID

It reads the same socket wait does — the in-process UI, or the local session under local-backend = session — with the same identity check, and no snapshot. There is no poll fallback.

A local-backend switch in flight is the one refusal not passed through: while identify names one, or the subscribe is refused busy (or host-unavailable from a server older than busy, with identify still naming the switch), events re-reads identify every 100 ms until the switch settles, with no deadline. It says so once on stderr — waiting for a local-backend switch to settle — so stdout stays JSON lines.

Exit When
0 The terminal envelope arrived (printed as the last line), or the reader of stdout went away (events \| head -n1)
1 connection The stream closed without a label or skipped a revision — nothing is resolved again; run it again
1 the server's own The server does not serve the stream: the Swift Mac app, an older Roost. Its refusal is passed through verbatim (not-implemented, unknown-op), as is every refusal but a switch in flight
2 usage A bad command line, including a host --tab

events is always JSON, with or without --json; a failure still goes through the one error envelope.

project subcommands

roostctl project list
roostctl project create --name "scratch" --cwd ~
roostctl project ensure --name "scratch"                 # --cwd defaults to $PWD
roostctl project ensure --name "scratch" --cwd ~/scratch
roostctl project rename --id 1 --name "main"
roostctl project delete --id 2
roostctl project reorder --order 1,3,2

project delete cascades to the project's tabs. project reorder is the same shape as tab reorder — any id not in --order keeps its prior position.

project ensure finds the project with this exact name, or creates it at --cwd (defaulting to $PWD) if none exists — atomic on the server, so two callers racing the same name converge on one project (#221) rather than each creating their own — and never activates it. Always prints {"project", "created"} (the project.ensure wire result), with or without --json: project create is the only other project verb with a one-line human form, and rename/delete/reorder print nothing without the flag, so there is no single convention ensure could join instead.

Refuses unsupported (exit 1) against a server whose identify --json ops field doesn't list project.ensure — the Swift Mac app today. The refusal names the manual route — project list --json to find an existing project by name, project create to make one — rather than falling back to it itself: a list-then-create fallback inside ensure would reopen the very race (#221) it exists to close.

open

roostctl open --project "review" --cwd "$PWD" -- bash -lc 'make test'
roostctl open --project "review" --title "review" --focus -- vim
roostctl open --project "review" --hold -- make test   # keep the tab open after it exits

The one-shot agent verb: project.ensure (find-or-create, by exact name), then tab.open in the ensured project. It replaces composing those two calls by hand, and the older project list-then-project create recipe. Each call is atomic on the server, so unlike that recipe it cannot race another caller into a second project of the same name (#221). --cwd defaults to $PWD and is used for both calls: the project (if this call creates it) and the new tab. Without -- cmd… the tab opens the default shell, same as tab open; --hold, --focus and --no-activate compose exactly as they do there (--focus with --no-activate is exit 2 usage, before anything is sent).

Always prints {"project", "tab", "created"} — the ensured project, the opened tab, and whether the project was just created — with or without --json: this is an agent verb, not a human-typed one.

Refusals:

Exit code When
1 unsupported This server's identify --json ops doesn't list project.ensure (the Swift Roost.app today) — names the manual route (project list --json + tab open --project-id) rather than racing it itself (#221)
2 usage identify --json's local_backend_switch shows a backend switch in progress — the project set is mid-flight, so a name resolved against it may not hold; retry once it settles
1 the server's own tab.open's own refusal, verbatim — most notably not-found if the ensured project vanished between the two calls (a backend switch landing in that window, say). The two calls are not atomic with each other, only each is atomic on the server, so this is possible even though ensure itself never races another ensure

screenshot

Capture a PNG of the running UI's whole window (sidebar + tab bar + active terminal), rendered in-process by the UI itself. Because it re-draws the view tree rather than grabbing screen pixels, it needs no screen-recording permission and works even when the window is unfocused, behind other windows, or offscreen — handy for confirming a UI change without OS screen capture.

roostctl screenshot --out shot.png        # write a file
roostctl screenshot --scale 2 --out shot.png   # 2x super-sampled
roostctl screenshot > shot.png            # raw PNG bytes to stdout

--scale is 1 (default, logical window size) or 2. With --out the CLI writes the file and prints the dimensions + byte count to stderr; without it, the raw PNG bytes go to stdout (nothing else is printed, so the stream stays binary-clean). --json requires --out and prints {"out": "<path>", "bytes": N} on stdout instead of the stderr line. Backed by the app.screenshot IPC op — see ipc.md.

render-stats

Read the running UI's render-path counters — the only way to measure the real draw path, since TerminalWidget::draw needs a live renderer no unit test can construct.

roostctl render-stats             # running totals since process start
roostctl render-stats --reset     # read, then zero for a clean next delta

Prints one counter per line plus two derived averages (ns_per_refresh, ns_per_draw, shown as - when the matching call count is zero). --reset zeroes the counters after the read, so the read-reset / run workload / read pattern gives you a delta directly.

refresh_* covers the snapshot rebuild that walks libghostty's render state (with rows_rebuilt / cells_walked measuring what it touched); draw_* and fill_text_calls cover the widget draw pass. Note that roostctl screenshot re-renders the window and so inflates the draw counters — read before capturing, or reset after. Both Rust UIs report real numbers, and fill_text_calls counts sprite-rendered cells (box drawing, blocks) as glyph draws on both, so it is comparable across them. Backed by the app.render_stats IPC op — see ipc.md.

palette subcommands

Drive the command-palette overlay: palette open, palette state, palette query <text>, palette activate <id>, palette dismiss, palette present --items <json>. Activating a row runs the same command its keybind would (a command row's id is its keybind action), so this is a command-dispatch surface, not just a UI poke. Each subcommand prints the resulting palette state (a > marks the highlighted row); --json emits the structured result.

roostctl palette open                      # the command palette
roostctl palette open --kind launcher      # the command launcher
roostctl palette open --kind custom        # the script-backed provider palette
roostctl palette open --kind agents        # the agent-jump palette
roostctl palette state                     # current rows / filter / selection
roostctl palette query theme               # set the filter
roostctl palette activate new_tab          # confirm the row (runs its command)
roostctl palette dismiss

--kind is commands (default), launcher, custom, or agents; any other value errors invalid-param. palette activate <id> errors not-found if no palette is open or no visible row has that id. Backed by the palette.* IPC ops — see ipc.md.

agent subcommands

Wire Roost's hook entries into each supported agent's own configuration file, and take them back out. Every verb but one reads and writes dotfiles directly and works with nothing running; bare agent set is the exception — it dials the running UI, because only the UI can reach the hosts this machine is connected to.

roostctl agent status                        # per agent: installed, wired, up to date
roostctl agent ensure [--json] [--startup]    # reconcile to `agent-hooks`; --startup never removes
roostctl agent set claude,codex [--json]      # via the running UI: set the key here, raise every connected host
roostctl agent set claude,codex --local       # set `agent-hooks` to exactly this list here, and wire it
roostctl agent set off --local                # set `agent-hooks = off`, and unwire everything
roostctl agent install claude                 # or --all; explicit wins over `agent-hooks = off`
roostctl agent uninstall claude               # or --all

ensure reads the agent-hooks key from config.conf (config.md) — the same value the UIs act on at startup, through the same parser (roost-cli depends on roost-ui-model for exactly this) — and reconciles this machine's files to match: wires what the key allows and takes out what it does not. Bare agent ensure is that full reconcile; --startup is the narrower shape a UI launch runs — wire and refresh what the key names, but never remove anything, because a launch must not undo a hook someone added by hand or react to a key that changed while the app was closed. An unanswered key (nobody has run the consent dialog or agent set yet) is a no-op for either shape: nothing is wired, and nothing already wired is touched.

set <list|off> answers the consent question from a terminal: the argument is a comma list of agent names (claude, codex, grok, cursor, opencode) or the literal off, and an unrecognised name refuses the whole list rather than narrowing it silently.

Bare set puts agent.set_hooks to the running UI, which writes this machine's agent-hooks key, reconciles its files, and raises every connected non-localhost host to at least the same list in the same call — so a headless box and a desk box agree without either editing the other's dotfiles. It prints where the key landed, what changed here, and one line per host (--json prints the op's reply verbatim), and exits non-zero if anything failed here or a host could not be asked.

set <list|off> --local writes the key and reconciles this machine's files directly instead, the same way ensure does — with nothing running, and reaching no host at all, so a connected host's live session does not pick up the change until it reconnects.

agent install and agent uninstall take an agent name or --all, and deliberately ignore agent-hooks = off — an explicit verb always wins. Both also move the key rather than leaving it alone: install unions the named agent(s) into it (so the next ensure does not treat what you just asked for as unconsented), and uninstall narrows it back out, spelling the result off when nothing would be left allowed. status changes nothing on disk; each row names the agent, whether its config directory is present, whether the resolved agent-hooks key allows it, whether Roost's entries are actually wired on disk, and whether they're at the current integration version.

See the Agent Hooks guide for the mechanism, the wire format each agent gets, and the guarantee about what a merge does and doesn't touch.

agent-hook

Internal: the one hook entrypoint every supported agent's installed command invokes — roostctl agent-hook <agent>, where <agent> is claude, codex, grok, cursor, or opencode (gx reports through grok; there's no separate name for it). Reads the agent's JSON event payload from stdin, takes the event name from the payload itself (hook_event_name — there is no --event flag, since one installed command string has to serve every event), dials ROOST_SOCKET for the tab named by ROOST_TAB_ID, and translates the event into a tab.agent_report. Always exits 0 with {} on stdout, whatever happens — an agent Roost has no adapter for, a malformed payload, an unreachable socket, all drain stdin and answer the same inert {}, because Claude's and codex's PermissionRequest are decision hooks whose dialog blocks on this process, and anything else may be read as a block. Unlike claude-hook below, it never falls back to the default bundle-profile socket when --socket/ROOST_SOCKET is absent — that could otherwise deliver an event into an unrelated running Roost.

claude install

An alias of roostctl agent install claude, kept so the command existing scripts already run keeps working. It no longer writes ~/.config/roost/claude-settings.json and no longer prints a shell alias snippet — wiring goes into Claude's own ~/.claude/settings.json, and the installed command finds roostctl through $ROOST_AGENT_HOOK rather than a baked-in absolute path.

roostctl claude install

If you followed the old instructions, roostctl doctor's agent.claude.legacy_settings check will tell you what is left over and how to remove it. There is no --force; it exits 2.

claude-hook

A thin alias of agent-hook claude, kept so a ~/.claude/settings.json an earlier Roost wrote (or a hand-installed one following docs/development/claude-testing.md) keeps working. It accepts the canonical event names plus the older kebab-case ones (session-start, prompt-submit, notification, stop, session-end) an early Roost wrote. Unlike agent-hook, it falls back to the ordinary target resolver (--target / ROOST_BUNDLE_PROFILE / auto-detect) when no explicit socket is given, matching the by-hand invocation that doc describes. Reads the hook payload from stdin, looks up $ROOST_TAB_ID, and translates lifecycle events into a tab.agent_report. Always exits 0 with {} on stdout (Claude treats nonzero hooks as failures). Silently no-ops when run outside a Roost tab.

session subcommands

Start, stop, and inspect the headless roost-session daemon (HS-1a, plan 035) — a workspace + PTY supervisor with no UI attached, for host-sessions (a workspace left running on a machine with nobody watching, e.g. a remote host). See ipc.md for the socket posture (same-UID check, 0700/0600) and the session ops these verbs drive.

roostctl session start
roostctl session status
roostctl session stop

These three verbs are a deliberate carve-out: a session is not a UI, so they never go through --target / ROOST_BUNDLE_PROFILE / auto-detect — they resolve the Session bundle profile's socket directly, and start has to work when nothing is listening at all. Any other op (tab.list, tab.open, …) reaches a running session explicitly — roostctl --target session <op> or roostctl --socket <path> <op> pointed at that same socket (#475) — never by ROOST_BUNDLE_PROFILE (session is refused there like any other unrecognized value) or auto-detect, which never probes it. An op the session does not serve answers with the server's own error for that op, not a session-specific one: unknown-op for host.* and agent.set_hooks, internal: no UI attached for app.* window/UI ops, except app.activate, which answers {} and does nothing.

session start spawns roost-session start, which daemonizes and seeds its first project from the calling shell's cwd on a fresh state file. It then polls session.identify on the socket before returning, so exit 0 means a session answered, not merely that a process was launched — both a fresh start and confirming an already-running session print their identity and exit 0; either verdict that never gets confirmed exits 1. The daemon binary is located next to roostctl first, then on PATH; ROOST_SESSION_BIN overrides the search outright (used by tests and a from-source cargo run).

session stop calls session.stop and prints the reap report, then polls until the socket is actually gone before exiting — stopping something that is not already running is a success (systemctl stop style), so session stop always exits 0 short of a genuine fault reaching the socket.

session status prints the session's identity and tab count. When no session is listening it prints not running (no session at …), reports not-running on stderr and exits 3, matching systemctl status's convention so a script can branch on the code alone without parsing output; a socket that exists but will not answer is a real fault and exits 1 (connection), not 3.

Verb Exit 0 Exit 1 Exit 3
session start a session confirmed serving (fresh or already-running) spawn failed, or no session answered session.identify within the confirm window —
session stop stopped, or already not running a socket exists but never answered, or the reap timed out —
session status a session answered; identity printed a socket exists but would not answer no session is running

host subcommands

Manage the client-side saved-host registry for host sessions — add, list, remove, connect, disconnect, status. Unlike session, a saved host is UI state (Workspace.hosts in the running UI's own state.json), not a session daemon's workspace — so every verb here addresses the ordinary UI socket (--target / auto-detect / ROOST_BUNDLE_PROFILE, same as tab/project), never --socket against a session. Each verb drives the matching host.* IPC op one-to-one, so the CLI can never diverge from what a palette click does. See ipc.md for the wire.

roostctl host add --label pop-os --target /home/charlie/.local/state/roost/roost-session.sock
roostctl host add --label pop-os --target ~/.roost-session.sock --verify
roostctl host list
roostctl host list --json
roostctl host remove --id 3f9a2b7c1d4e4f5a
roostctl host connect --id 3f9a2b7c1d4e4f5a
roostctl host disconnect --id 3f9a2b7c1d4e4f5a
roostctl host status
roostctl host status --id 3f9a2b7c1d4e4f5a --json

--target accepts an SSH destination (workbox, user@host, ssh://user@host:port — only the ssh:// spelling carries an explicit port; ssh://[::1]:22 for a literal IPv6 host), a Unix socket path (anything containing /), or localhost — see roost_ipc::ssh::classify for the full rule table. host add is registry-only by default: it saves --label/--target without dialing anything, so a typo'd target still saves cleanly — the sidebar's connection dot is what reports it at the next connect attempt. --verify additionally dials session.identify against --target first (through the same target-resolution roost_ipc::ssh::verify_transport and the Add Host dialog's own "Add & Connect" both use) and refuses to save on an unreachable or incompatible session — the CLI equivalent of the dialog's validation, stated once so the two bars cannot drift apart. Over an SSH target this is a mux-less probe: a one-shot ssh exec outside any ControlMaster (ControlMaster=no, no -S), so a stale or wedged control socket from a previous attempt can never make the probe report a false positive, and nothing is left running afterward, win or lose.

host connect starts a new, symmetric connection alongside any this client or another already has open — reconnecting to an already-connected host claims nothing from anybody — and, on a localhost target, spawns the session first if nothing is listening. It returns once the attempt is under way, not once it settles — watch the sidebar or poll host status for the connected/needs-restart outcome. host disconnect never stops the session; its shells keep running and a later connect picks them back up.

host list prints one line per saved host — id label target=… state=… last_connected=…. The state= column is a best-effort second call to host.status after the registry read: a target that answers unknown-op (a session socket, the Swift app) or errors for any other reason prints state=? for every row rather than failing the listing. host list --json is deliberately the registry alone, unchanged — a script that wants connection state calls the op that owns it.

host status is that op. It reports, for every saved host (or just --id), the state the sidebar's band is drawn from: generation (which attempt the host is on — the monotonic edge to poll against), state, reason (the band's untruncated input), rollup (the band's own output), and retry (an armed auto-reconnect's delay_ms/attempt/budget/armed_at, absent when nothing is scheduled, plus retry.reason — the classified failure that armed the rung, ssh-only, read it whenever it is present rather than gating on attempt). A live connection additionally carries connect (session_id, reduced_fidelity, resumed, from_revision? — plan 056 §3.1/§3.2, R14/#447 and R11/#442) and tabs (how many rows the sidebar section is currently listing, 0 included, always present). --json prints the op's result verbatim and is what scripts and the functional harness assert on; the human form is id label state rollup, followed by the host's detail on indented lines when there is one — a settled launch failure's full text, which the band has no room for. retry's delay_ms/attempt/budget/armed_at stay --json-only, but retry.reason does not: while a rung is armed the band, and so the human form's rollup, has to read reconnecting in 8s (3/10), which is exactly the moment the cause would otherwise be unreadable, so the human form prints retry.reason too, on its own indented line right after the reason/detail lines and before the reduced-fidelity line: armed because: <reason>. Also --json-only are payload_kind and connect/tabs: payload_kind names what the last attach on that host is being decoded as — vt there means the connection is on the reduced-fidelity fallback a libghostty build skew lands on — but it appears only once a tab has actually attached, while connect.reduced_fidelity is known the moment the connection is up. That is what the human form prints instead, keyed on connect.reduced_fidelity rather than payload_kind for exactly that reason: whenever it is true, an indented line follows the reason/detail lines: reduced fidelity: this Roost and the session pin different libghostty builds; update or restart the session from the sidebar. See ipc.md for the field-by-field wire shape.

There is no roostctl host stop: the palette's Stop Session verb goes straight onto the host's own connection as an ordinary session.stop, not through a client-side host.* op, so there is nothing yet for a CLI verb to drive. The fidelity fix-it verbs (host:update:<id> on an ssh host, host:restart:<id> on localhost) are the same story: UI-only, deliberately, with no roostctl twin. Plan 039 §3.5's rule is that a machine is never prompted by a connect it did not ask for; these verbs exist only on a person's already-connected, reduced-fidelity host, and their first remote activity is a consent card (ssh) or a restart confirmation (localhost) — exactly the shape Stop Session already set. No new IPC op backs either one.

Swift Mac app note: roostctl host * against --target mac answers unknown-op — a documented, permanent boundary, not a gap. Host sessions are iced-only (the Linux roost build and the experimental Roost-Iced Mac app); the Swift Roost.app never grows this surface.

rpc

Call any IPC op directly, by name — the escape hatch for an op that has no verb of its own yet, or for a script that would rather speak the wire format straight through.

roostctl rpc tab.list
roostctl rpc tab.write '{"tab_id":"4","data":"bHM="}'
echo '{"tab_id":"4","data":"bHM="}' | roostctl rpc tab.write -   # params from stdin
Argument Description
<op> The op name, passed through verbatim — no local validation against the ops this build knows. An op the server does not serve answers the server's own unknown-op, passed through the same way.
[params] The op's params as a JSON object literal, or - to read one from stdin. Omitted ⇒ {}. Anything that is not valid JSON, or that parses to something other than an object (an array, a string, a number), is refused with usage (exit 2) before the socket is dialled.

See ipc.md for op names and their params, and roostctl identify --json's ops field for exactly which ops the Roost this socket reaches serves right now. rpc never re-routes an op: under local-backend = session a raw op goes to the socket roostctl dialled — from inside a session tab, the window that owns it — not to the session that tab dump, wait and events read from.

rpc is always JSON, with or without --json on the command line: the op's result object, pretty-printed on stdout — the same shape every other IPC-backed verb prints under --json. A failure still goes through the one CliError envelope every verb uses: roostctl: <code>: <message> on stderr normally, or {"error":{"code","message"}} when --json is also given.

It bypasses the tab policy. rpc is not in the list of verbs Which tab a command acts on governs, and it does not read ROOST_TAB_ID either — the caller writes whatever ids params needs directly, so there is no --tab for this verb to resolve. That also means rpc is not a way around --tab: a verb that needs --tab or ROOST_TAB_ID to guard a mutation (tab close, tab send, …) stays that named verb: rpc tab.write with a hand-picked tab_id skips none of that verb's own checks, it just skips the CLI's tab-resolution convenience.

skill

Print the agent skill — the same skills/roost/SKILL.md the Claude Code plugin and npx skills add install, byte for byte, so the binary and the published skill cannot disagree:

roostctl skill              # the skill as markdown, on stdout
roostctl skill --json       # {"topic","format","content"}

Needs no running Roost — an agent reads the skill to learn how to find one. --json prints {"topic": "roost", "format": "markdown", "content": "<the skill's markdown>"}; without it, the raw markdown goes to stdout. See the Agent Skill guide for what the skill covers and how it relates to Agent Hooks.

schema

Print the wire's JSON Schema bundle — the same docs/reference/api/roost-ipc.schema.json this repo checks in, byte for byte, so the binary and the file cannot disagree:

roostctl schema

Needs no running Roost and no socket. Always prints the same JSON document — --json changes nothing, because the printed file already is the payload. See Machine-readable schema for the bundle's shape and how it is generated and pinned.

doctor

A read-only diagnostic for the Roost integration — it reports and links, it never repairs, installs, or mutates anything, so it's safe to run blind whenever something looks broken (a dot that won't light, hooks that don't fire, a stale socket).

roostctl doctor
roostctl doctor --tab 7
roostctl doctor --json
roostctl doctor -v
roostctl doctor --color=always
Flag Type Default Description
--tab int $ROOST_TAB_ID / the UI's active tab Inspect this tab instead. Doctor reads the env var itself rather than through the parse every other --tab command shares, so an unparsable $ROOST_TAB_ID becomes a diagnostic (env.tab_id: fail) instead of exit 2 usage
--json flag false Machine-readable report (the global flag; see JSON output)
-v / --verbose flag false Print the full per-check report — all 40 entries with details and doc links — instead of the one-line-per-section summary. Ignored by --json, which always carries everything
--color auto | always | never auto Colorize the text output. auto enables color only when stdout is a TTY, NO_COLOR is unset or empty, and TERM is not dumb; always bypasses all three checks; never always disables. Per https://no-color.org/, NO_COLOR= (present but empty) does not disable — only a non-empty value does. Ignored by --json

Doctor diagnoses the Roost any other verb would dial: from inside a local session tab, that is the window that owns the session, and ui.target names both (Inside a session tab).

The report is six sections. Each declares one of three scopes: process (the shell/process that invoked doctor), ui (the Roost instance doctor reached), tab (the selected tab) — a process fact is never used to judge a tab fact unless the selected tab is doctor's own tab.

Section Scope Answers
env process Are ROOST_TAB_ID / ROOST_SOCKET set, and valid?
ui ui Which UI did target resolution pick, is its socket reachable, does identify succeed, does its version match roostctl's, and does it speak the current agent-state wire format?
shell process (shell.marks_observed is tab-scoped) Is the shell-integration contract offered, can this shell/version emit the OSC 133 marks that drive the running dot, and has a mark actually been observed on doctor's own tab?
tab tab Which tab is selected — the one check in the section, and it can fail if a --tab/$ROOST_TAB_ID no longer exists — then, for that tab, its four agent axes (shell state, agent lifecycle, attention, ownership), the state derived from them, whether raw OSC 9/99/777 is currently suppressed, and the agent reports the tab's server has dropped (how many, the latest, and a note when the latest was dropped while no agent owned the tab and that agent doesn't own it now: if it is still running, it has lost the tab). Those seven are observations, not verdicts.
claude process (claude.observed is tab-scoped) Is claude on PATH, does ~/.claude/settings.json parse and register every lifecycle event Roost maps, does each hook command resolve to a runnable roostctl, has this tab actually seen a Claude hook fire? Pre-046 checks, kept for the settings-file shape they've always covered — see the agents section below for the other four agents and for Claude's newer, agent-hooks-specific facts
agents process (the owning checks are tab-scoped) Is $ROOST_AGENT_HOOK set and executable from this tab; per agent (claude, codex, grok, cursor, opencode), is Roost's hook entry present and at the current integration version, and does that source currently own a tab on this UI; for codex, does its trusted_hash on disk match what codex would compute; is a legacy ~/.config/roost/claude-settings.json or shell alias still lying around? See the Agent Hooks guide

Every entry carries a stable id (env.tab_id, ui.socket, shell.marks_capability, claude.hook_command, …) and a kind: check or observation.

Checks carry a verdict — one of ok, warn, fail, skipped. skipped is not a fourth verdict so much as the absence of one: the check's subject is absent (not inside a Roost tab, Claude not configured) or doctor genuinely cannot tell (a shell whose family or version it can't identify).

Observations carry no verdict at all — status is null — because they report a fact with no correct value: the selected tab's four agent axes (plus the state derived from them, whether raw OSC 9/99/777 is suppressed, and its dropped reports), ROOST_SOCKET, and the current shell. The exception worth naming: those same seven tab.* axes carry skipped rather than null when the UI predates the agent state model, because then they genuinely cannot be observed — tab.ownership: null ("nothing owns it") and tab.ownership: "skipped" ("can't tell") are different findings, and a --json consumer shouldn't have to parse prose to know which.

Every fail/warn still prints a link to the doc page that explains it; skipped entries and observations do not — there's nothing to go read about a fact.

Real output, captured on a dev checkout with ROOST_TAB_ID unset and --target mac pinning the target at a stale Roost.app socket left over from an earlier crash (so the example doesn't depend on whatever else happens to be running on the capture machine — an ordinary roostctl doctor with nothing running looks the same, just with ui.target saying auto-detect instead of --target). This is the default view — one line per section, clipped to a fixed width so it stays scannable in a narrow terminal:

roostctl doctor — roostctl 0.0.19 (run `roostctl doctor -v` for the full report)

[–] Environment         not running inside a Roost tab
[✗] Roost UI            /Users/charliek/Library/Caches/Roost/roost.sock: stale — the socket file ou…
[–] Shell integration   not running inside a Roost tab
[–] Selected tab        no tab selected — pass --tab, set ROOST_TAB_ID, or give the UI an active tab
[–] Claude Code         the selected tab's ownership is unavailable (no tab.list from a running UI)
[–] Agents              not running inside a Roost tab

• 2 issues found — exit 1 (https://charliek.github.io/roost/reference/cli/#exit-codes):
    ✗ ui.socket    → https://charliek.github.io/roost/reference/cli/#environment
    ✗ ui.identify  → https://charliek.github.io/roost/reference/cli/#identify

Notice Claude Code and Agents read skipped/–, not fail, even though no UI is reachable: everything in the agents section that doesn't need a live tab (wired, trust, legacy_settings) is read straight off the agents' own config files on disk, so it still has something to say with nothing running — only the tab-scoped owning checks and agent.hook_binary (which needs to be inside a tab at all) come back skipped here.

-v prints all 40 entries grouped by section, with the status column blank for null-status observations (not for skipped — that word still prints, because it is a status) and a doc link under every fail/warn. Same capture, -v, trimmed with […] to the sections that show every row shape — the elided one is "Shell integration", all skipped here because nothing is inside a Roost tab:

roostctl doctor — roostctl 0.0.19

Environment (process)
  skipped env.tab_id               not running inside a Roost tab
          env.socket               unset — target resolution falls through to --socket / --target / auto-detect

Roost UI (ui)
  ok      ui.target                --target → /Users/charliek/Library/Caches/Roost/roost.sock (auto-detect would try: /Users/charliek/Library/Caches/Roost/roost.sock, /Users/charliek/Library/Caches/Roost-linux/roost.sock, /Users/charliek/Library/Caches/Roost-iced/roost.sock)
  fail    ui.socket                /Users/charliek/Library/Caches/Roost/roost.sock: stale — the socket file outlived its listener; Roost crashed or was killed
                                   → https://charliek.github.io/roost/reference/cli/#environment
  fail    ui.identify              no connection: io error: Connection refused (os error 61)
                                   → https://charliek.github.io/roost/reference/cli/#identify
  skipped ui.version               roostctl 0.0.19 — no UI reached, nothing to compare
  skipped ui.agent_model           undetermined — tab.list failed: no connection: io error: Connection refused (os error 61)

  […]

Selected tab (tab)
  skipped tab.selection            no tab selected — pass --tab, set ROOST_TAB_ID, or give the UI an active tab
  skipped tab.shell_state          unavailable (no tab.list from a running UI)
  skipped tab.agent_lifecycle      unavailable (no tab.list from a running UI)
  skipped tab.attention            unavailable (no tab.list from a running UI)
  skipped tab.ownership            unavailable (no tab.list from a running UI)
  skipped tab.derived              unavailable (no tab.list from a running UI)
  skipped tab.raw_osc              unavailable (no tab.list from a running UI)
  skipped tab.dropped_reports      unavailable (no tab.list from a running UI)

Claude Code (process)
  ok      claude.binary            2.1.261 (Claude Code)
  ok      claude.settings          /Users/charliek/.claude/settings.json parses
  ok      claude.hook_events       all 11 events registered
  ok      claude.hook_command      all 11 events have a current Roost command
  skipped claude.observed          the selected tab's ownership is unavailable (no tab.list from a running UI)

Agents (process)
  skipped agent.hook_binary        not running inside a Roost tab
  ok      agent.claude.wired       wired@v3
  skipped agent.claude.owning      unavailable (no tab.list from a running UI)
  ok      agent.claude.legacy_settings no leftover ~/.config/roost/claude-settings.json or shell alias found
  ok      agent.codex.wired        wired@v3
  ok      agent.codex.trust        8 trusted hashes match what codex would compute
  skipped agent.codex.owning       unavailable (no tab.list from a running UI)
  ok      agent.grok.wired         wired@v3
  skipped agent.grok.owning        unavailable (no tab.list from a running UI)
  ok      agent.cursor.wired       wired@v3
  skipped agent.cursor.owning      unavailable (no tab.list from a running UI)
  ok      agent.opencode.wired     wired@v3
  skipped agent.opencode.owning    unavailable (no tab.list from a running UI)

• 2 issues found — exit 1 (https://charliek.github.io/roost/reference/cli/#exit-codes):
    ✗ ui.socket    → https://charliek.github.io/roost/reference/cli/#environment
    ✗ ui.identify  → https://charliek.github.io/roost/reference/cli/#identify

Paths are this machine's own (/Users/charliek/...) — expect your own absolute paths, not these. The five agent.*.wired/agent.codex.trust oks above reflect this dev checkout's own machine already having run roostctl agent ensure for real — an agent whose config directory doesn't exist at all shows skipped: not installed instead, and a present-but-never-wired agent shows warn: not wired rather than ok.

--json carries the same facts as JSON — a top-level schema_version: 2, an explicit exit_code (so a script never has to re-derive "did anything fail" from the check list), and every entry's stable id, kind (check or observation), and status — null for observations, one of ok/warn/fail/skipped for checks. That's the shape to script against; the text output's column widths are not. --json is also unaffected by -v and --color: it always carries all 40 entries and never contains a color escape, regardless of either flag.

Environment

Variable Effect
ROOST_SOCKET Override the UI socket the CLI dials. In a local session tab it names the session, and a verb goes to the window that owns it instead (Inside a session tab)
ROOST_TAB_ID Default tab id when --tab is not given. A command that changes a tab refuses without one of the two (why)
ROOST_ROOSTCTL Set by the UI for provider scripts: absolute path to its own roostctl. Best-effort — may be absent if the UI can't resolve its bundled/sibling CLI, so scripts keep the "${ROOST_ROOSTCTL:-roostctl}" fallback (see Where roostctl lives)
ROOST_AGENT_HOOK Set by the UI (or roost-session) on every tab: absolute path of the roostctl (or roost-session) that understands agent-hook <agent>. Every hook entry Roost installs into an agent's config reads this indirectly through a shell fallback rather than calling roostctl directly, which is what keeps the installed command host-independent — see Agent Hooks. Resolved the same sibling → bundled-Resources/bin → PATH ladder as ROOST_ROOSTCTL; omitted, like that variable, when nothing resolves
ROOST_DEBUG If set, claude-hook and agent-hook write failure messages to stderr
ROOST_AGENT_HOOKS_FORCE Set to 1 to let agent ensure/install/uninstall write real dotfiles while ROOST_TEST_MODE=1 is also set — without it, the agent-hooks install engine refuses to run at all under ROOST_TEST_MODE=1, belt and braces against a test writing into a real machine's dotfiles
ROOST_SESSION_BIN Overrides where session start looks for the roost-session binary (default: next to roostctl, then PATH)
ROOST_SSH_BIN Overrides the ssh binary a host's SSH transport execs (default: ssh on PATH) — read by host add --verify against an SSH target and by the UI's own tunnel. See paths.md.

ROOST_SOCKET / ROOST_TAB_ID / ROOST_AGENT_HOOK are auto-set by the UI when it spawns a tab's shell. Set them by hand only when invoking the CLI from outside a Roost tab (e.g. a CI runner). The UI side also honors ROOST_CONFIG (config path) and ROOST_BUNDLE_PROFILE (mac / linux / iced) — see Paths & Environment. roostctl reads ROOST_BUNDLE_PROFILE too, as the env-var form of --target; an unrecognized value is a hard error there ("unknown ROOST_BUNDLE_PROFILE value … expected mac, linux, or iced") rather than the UI's warn-and-fall-back. --target alone additionally accepts session (#475) — ROOST_BUNDLE_PROFILE=session is one of the values that hard error names as unrecognized, by design: a session is reachable only explicitly.

Exit codes

Every failure prints one error line on stderr, never on stdout:

roostctl: <code>: <message>

A command line clap refuses keeps clap's usage text after that line, and a command given no subcommand at all (roostctl tab) prints its help instead. doctor and session status still print their report on stdout when they fail.

With --json (read off the raw command line even when parsing itself failed), the line is a JSON envelope instead:

{"error":{"code":"usage","message":"no --tab and ROOST_TAB_ID is unset; …"}}

code is the stable part to branch on; message is for people.

Exit code Meaning
0 — Success
2 usage A bad command line, including a command that changes a tab given no --tab and no ROOST_TAB_ID. A parser error keeps clap's usage text in the message; --help and --version print to stdout and exit 0
1 no-target Auto-detect found nothing listening at any known socket
1 ambiguous-target Several Roost UIs are running; pass --target
1 connection Dialing, reading or writing the socket failed, or the stream dropped (wait after a second loss or a stopping session; events on any drop or gap), or a call went unanswered for 30 s (wait --timeout 0 or --no-timeout)
1 the server's own The server refused the request; its code is passed through verbatim (not-found, invalid-param, unknown-op, …)
1 unsupported This server does not serve an op the command needs
1 checks-failed doctor found a failing check; the report itself is on stdout
1 failed Something on this machine outside the wire: a file that could not be written, a binary that could not be found, an unset $HOME
3 not-running session status found no session running
4 timeout wait's or tab prompt's condition did not hold before --timeout
4 stalled tab prompt wrote the prompt and the tab reached no running within --activity-timeout. CLI-local: no server answers this code

A command can also exit 1 without an error line when it has a partial outcome to report: agent install/uninstall/ensure/set print what they did on stdout and exit 1 if any agent (or, for agent set, any connected host) failed.

doctor exits 1 when any check's status is fail; warn never affects the exit code, and neither does skipped — a check that couldn't be judged is not a check that failed. Observations don't enter into it either, since they carry no status to fail with. Note that "no Roost UI is running" is itself a failed check (ui.socket / ui.target), so roostctl doctor exits 1 whenever nothing is listening — that's by design, not a bug: the whole point of the ui section is to fail when there's nothing there.