Drive Roost from scripts and agents¶
Roost serves one JSON socket that everything else — the UI's own
buttons and hotkeys, roostctl, an agent skill — drives. This guide is
for a script or an agent driving that socket, mostly through
roostctl. See reference/cli.md for the full
verb-by-verb reference and reference/ipc.md
for the wire format itself, if you're talking to the socket directly.
The model¶
A project is a named working directory in the sidebar, holding
tabs. A tab is one terminal running one shell or command — Roost
has one window, no splits, no panes. Every tab has a state: none,
running, needs_input, or idle.
A plain shell only reaches running and none, and only with shell
integration — Roost loads the OSC 133 prompt marks for zsh and bash 4.4
or newer: running while a foreground command runs, none at its
prompt. A shell without the marks stays none the whole time (tab
list --json's shell_state reads unknown), so wait on a shell's
output text, not its state, unless you know shell integration is
loaded. An agent whose hooks report to Roost — see Agent
Hooks — drives the state itself: running during its
turn, needs_input when it's waiting on you or its turn failed,
idle once its turn ends.
Ids and environment¶
Every shell Roost spawns gets ROOST_TAB_ID (its own tab) and
ROOST_SOCKET (the socket to dial) in its environment, and roostctl
reads both:
In a tab on a local session (local-backend = session, a fresh
install's default) ROOST_SOCKET names the session rather than the
window. roostctl there still talks to the window that owns the
session, as it does from a plain terminal, so palette, screenshot,
tab focus and tab open reach and move the window. The hooks,
events, wait and tab prompt stay on the session, which keeps them
working with the window closed. A command that names its target with
--socket or --target gets exactly that socket — --socket
"$ROOST_SOCKET" is the session itself. The whole rule is in
Inside a session tab.
Tab and project ids are integers, written as JSON strings ("7").
They belong to this running Roost: a restart, or a switch of the
local backend between in-process and a session (see Host
Sessions), mints new
ids for every tab, so re-resolve an id after either rather than
holding onto one across a process boundary.
identify and ops¶
Before anything else, check that a Roost answers, and see what it can do:
identify.ops lists the operations this socket serves right now.
open and project ensure refuse unsupported when ops is missing
project.ensure — today, the Swift Mac app; wait falls back to
polling and events and tab prompt fail outright when ops lacks
events.subscribe and there's no session backing the local tabs. See
the Mac paragraph below for the concrete shape of
that gap. identify.instance_id names the process: it changes on
every restart, and a resumed wait/events connection checks it to
tell "the same Roost, still running" from "a different one answered".
ops is a list of op names, not fields — it says whether a server
speaks tab.agent_report at all, not whether it accepts a field a
newer release added to that op's params (lifecycle_if, say).
TabAgentReportParams and every other request type is
deny_unknown_fields, so a field a server predates rejects the whole
request, not just that field. There is no runtime channel to detect
this the way ops detects a missing op — an integration that has to
work against more than one server version pins to the oldest one it
supports and stays inside what that version's schema accepts. See
The wire's schema below.
The wire's schema¶
roostctl schema prints the pinned JSON Schema
bundle — every op's
params and result, every event's data, draft 2020-12 — with no socket
and no running Roost:
That's the offline way to answer "does the server version I'm pinned
to accept this field": check the shape by hand, or validate a request
against it before sending, rather than finding out from a rejected
report. See cli.md#schema.
The verbs¶
The everyday primitives: find-or-create-and-open, block-until, stream,
call-by-name — plus two narrower ones for an agent turn specifically,
tab report and tab prompt.
open¶
Find-or-create a project by exact name, then open a tab in it —
project.ensure followed by tab.open, atomic per call so two
callers racing the same project name converge on one project (#221)
instead of each creating their own:
```bash roost-recipe out=$(roostctl open --project "review" --cwd "$PWD" --title "review" --json -- bash -lc 'make test') || exit 1 tab=$(printf '%s' "$out" | jq -er .tab.id) || exit 1
Prints `{"project","tab","created"}`, with or without `--json`. Opening
a tab selects it by default (in-process; see [The focus
rule](#the-focus-rule)) — pass `--no-activate` when the new tab should
not become the active one. See [`cli.md#open`](../reference/cli.md#open)
for `--hold`, `--focus`, `--no-activate`, and its refusals.
### `wait`
Block until a tab reaches a condition — the no-`sleep` synchronization
primitive:
```bash roost-recipe
roostctl wait --tab "$ROOST_TAB_ID" --text 'BUILD OK' --timeout 60
--state, --text, and --gone are the three conditions (at least
one required; all given must hold). See cli.md#wait
for the full condition/flag table, including --no-timeout for an
unbounded wait and --interval-ms.
events¶
Print the event stream, one JSON line per event, until it ends — useful for watching a tab from outside rather than polling it:
```bash roost-recipe timeout 10 roostctl events --tab "$ROOST_TAB_ID" | jq -c 'select(.event == "notification.fired")' || true
See [`cli.md#events`](../reference/cli.md#events) for the envelope
shape and exit codes.
### `rpc`
Call an operation by name when no verb covers it yet — the escape
hatch, not the everyday path. The line below only illustrates the
call's shape, on an op the `identify` verb already covers:
```bash roost-recipe
roostctl rpc identify '{}'
rpc bypasses the target policy below and does not read
ROOST_TAB_ID — see cli.md#rpc. It is
not a way around --tab for a verb that has one: use the named verb
for anything a verb covers.
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 --state
running, and the one verb that closes the gap between those two calls:
```bash roost-recipe roostctl tab prompt --tab "$ROOST_TAB_ID" --timeout 600 'Summarize the failing tests.'
**Two caveats, both load-bearing.** The activity gate it waits on is
**temporal, not causal** — it asks whether the tab reached `running`
after the prompt was sent, not whether this prompt is what did it, so
an unrelated turn starting in the window still satisfies it. And an
agent with no hooks reporting to Roost never reaches `running` at all,
so the gate times out by design (exit 4 `stalled`) — `tab prompt` needs
[Agent Hooks](agents.md) wired, or an agent reporting directly (see
[Reporting directly, without a hook](agents.md#reporting-directly-without-a-hook)),
not a plain shell. See [`cli.md#tab-prompt`](../reference/cli.md#tab-prompt)
for the full flag table and exit codes, including `--until` and
`--activity-timeout`.
### `tab report`
Report an agent-hook event straight to
[`tab.agent_report`](../reference/ipc.md#tabagent_report) — for a
script, or an agent with no adapter of its own that would rather shell
out than hand-build the request:
```bash roost-recipe
roostctl tab report --tab "$ROOST_TAB_ID" --source my-agent --session-id "$SESSION_ID" \
--claim --lifecycle working
See cli.md#tab-report for every
flag, and Reporting directly, without a
hook for the rules that
govern a caller driving this op by hand — ownership, metadata
namespacing, and pinning to the oldest server you support.
Waiting from a raw socket (not roostctl)¶
A client that isn't roostctl and wants to follow events instead of
polling — say, a language with its own JSON socket client — needs two
connections, because a connection that has called events.subscribe
answers nothing else:
- Pick the socket:
identify.local_session_socketif present, else the UI socket ifidentify.opslistsevents.subscribe, else there's no stream and you polltab.listinstead. - Subscribe on connection A. Keep the ack's
revisionandsession_id. - On connection B, check identity (
identify.instance_idon a UI socket,session.identify.session_idon a session socket) against the ack'ssession_id, then snapshot withtab.list. - Discard every batch on A whose
revisionis<=the snapshot's; apply the rest in order.
The full recipe, including every refusal and how to resume a dropped
stream, is ipc.md's "Waiting on a
condition" — this is a
pointer to it, not a restatement; roostctl wait and roostctl
events already implement it for you.
The focus rule¶
Never pass --focus or run tab focus unless the user (or the task)
actually asked to switch tabs — it raises the window and steals
attention from whatever the person is doing. Opening a tab selects it
by default, even without --focus — pass --no-activate (#503) when
the new tab should not become the active one, so open tabs, with
--no-activate, whenever the task doesn't call for switching to them.
An in-process Roost that selects it — the default — makes --focus
change only whether the window is raised and switched to, not whether
the new tab becomes the active one in the sidebar; --no-activate is
what actually keeps the previous tab active there. Under
local-backend = session, roostctl gets the same selection from a
plain shell, a script, or inside a session tab, which talks to the
window: the window selects the new tab unless
--no-activate was passed. Only a client on the session's own socket —
--socket "$ROOST_SOCKET" inside a session tab, or --target session
— moves the session's own active tab instead: the reply marks the new
tab is_active unless --no-activate was passed, but the window
doesn't move.
Target policy: 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 — needs --tab, or else $ROOST_TAB_ID. With
neither, it refuses before dialling anything and exits 2 usage: the
UI's active tab is whatever a person last clicked, which is no answer
for a command that writes to it. Every shell inside a Roost tab has
ROOST_TAB_ID already set to its own tab, so the bare form works
there; outside one — a CI runner, a second terminal, an agent driving
Roost from elsewhere — pass --tab explicitly. The read-only tab
dump and wait still fall back to the UI's active tab when
ROOST_TAB_ID is unset. See
cli.md's "Which tab a command acts on"
for the exact list and the refusal text.
busy, and retrying across a mixed-version fleet¶
A mutating op refused while a local-backend switch is in flight (see
Host Sessions) answers
{"code":"busy","message":"a local-backend switch is in progress"} —
the one refusal worth retrying rather than surfacing. Poll identify
until local_backend_switch is absent, then retry, re-reading any tab
or project id first: a switch replays the workspace under new ids, so
one held from before it may no longer resolve.
The mixed-version case. A server older than busy answered the
same condition host-unavailable instead, so on host-unavailable
re-read identify too: a local_backend_switch still present there is
that older server's spelling of busy; an absent one means the local
session really is down, and host-unavailable keeps its ordinary
meaning. A fleet with more than one Roost version in it — a host not
yet upgraded, say — needs both checks, not just the code string.
Exit codes¶
Every failure prints one line on stderr — roostctl: <code>: <message>,
or {"error":{"code","message"}} under --json — and code is the
stable part to branch on:
| 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 |
| 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, or a call went unanswered past its ceiling (30s under wait --timeout 0/--no-timeout) |
| 1 | the server's own | The server refused the request; its code is passed through verbatim (not-found, invalid-param, unknown-op, busy, …) |
| 1 | unsupported |
This server does not serve an op the command needs |
| 1 | checks-failed |
doctor found a failing check |
| 1 | failed |
Something on this machine outside the wire: a file, a binary, 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 never reached running within --activity-timeout — CLI-local, no server answers this code |
The authoritative version of this table, including the partial-failure
exception for the agent verbs, is
cli.md#exit-codes.
The Mac app¶
roostctl is not on PATH. The .dmg ships it inside the bundle,
at Roost.app/Contents/Resources/bin/roostctl (#261) — a
Finder-launched app gets a minimal PATH that doesn't include it, so a
script written against a bare roostctl needs a one-time symlink onto
PATH, or the full path. See cli.md's "Where roostctl
lives".
The Swift Roost.app also omits identify.ops entirely, which every
verb above treats the same as an ops list missing the op it needs
(#510):
openandproject ensurerefuseunsupported— useproject list --jsonto find a project by name, thentab open --project-id.waitfalls back to pollingtab.list(andtab.dumpfor--text) at--interval-msinstead of following the event stream.eventsfails outright with the server's own refusal (today,not-implemented) — there's no poll fallback for a continuous stream.tab promptneeds the event streameventsdoes, and there is no polling fallback for it either (a poll can't see a turn that starts and ends between twotab.listcalls) — it refusesunsupportedthe same way.
Roost.app through v0.0.20 predates --no-activate. tab open
--no-activate answers unknown-field there — reported verbatim, same
as any refusal from a server that doesn't recognize a field — rather
than silently opening the tab active anyway; later builds serve it.
open --no-activate never gets that far on any Swift build: open
needs project.ensure, which the Swift Roost.app does not serve, so
it refuses unsupported after identify. Either way no tab is
created, so there is nothing to wait on.
Everything else in this guide — identify, the target policy, tab
send/tab dump/notify, the exit-code table — works the same on
both platforms.
The agent skill¶
If you're driving Roost from inside a coding agent rather than an ad-hoc script, Agent Skill covers the packaged skill that teaches an agent the same rules this guide documents, and how to install it.