Skip to content

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:

printf '%s\n' "${ROOST_TAB_ID:-not in a Roost tab}"
roostctl set-title --title "building…"

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:

roostctl identify --json

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:

roostctl schema | jq '.ops["tab.agent_report"].params'

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:

  1. Pick the socket: identify.local_session_socket if present, else the UI socket if identify.ops lists events.subscribe, else there's no stream and you poll tab.list instead.
  2. Subscribe on connection A. Keep the ack's revision and session_id.
  3. On connection B, check identity (identify.instance_id on a UI socket, session.identify.session_id on a session socket) against the ack's session_id, then snapshot with tab.list.
  4. Discard every batch on A whose revision is <= 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):

  • open and project ensure refuse unsupported — use project list --json to find a project by name, then tab open --project-id.
  • wait falls back to polling tab.list (and tab.dump for --text) at --interval-ms instead of following the event stream.
  • events fails outright with the server's own refusal (today, not-implemented) — there's no poll fallback for a continuous stream.
  • tab prompt needs the event stream events does, and there is no polling fallback for it either (a poll can't see a turn that starts and ends between two tab.list calls) — it refuses unsupported the 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.