butai docs 1.3.0

Driving butai from inside a pane#

The daemon's REST API has always been able to spawn an agent, inspect its siblings and inject input. What was missing was a way for a program in a pane to find any of that: it had no idea which pane it was, no way to read a sibling's output as text, no blocking wait, and nothing telling it the surface existed.

This is that surface. It is deliberately the same one a shell-out plugin would use — there is no separate agent API.

Identity#

Every pane the daemon spawns — agent, process, or plain shell — carries:

variable value
BUTAI_PANE this pane's id
BUTAI_WORKSPACE the workspace it belongs to
BUTAI_SOCKET the socket this daemon is bound to

There is no separate "am I inside butai" flag: BUTAI_PANE is always set by the spawner and says more than a boolean would, so its absence is the test.

Because --ws defaults to $BUTAI_WORKSPACE and --socket to $BUTAI_SOCKET, a command run inside a pane already acts on its own workspace, on its own daemon.

butai whoami
# pane 7
# workspace 1
# socket /Users/you/.butai/butai.sock

Targets#

A target names a pane: an id (7), an agent name (reviewer), or stage. Optionally scoped, as <workspace>:<leaf> — 1:7, api:reviewer, 1:stage.

Pane ids come from one daemon-wide counter, so a bare id is unambiguous and needs no scope. When a scope is given alongside an id it is an assertion, not a lookup: 1:7 fails if pane 7 is not in workspace 1, so a script holding an id across a workspace teardown gets an error rather than acting on whatever pane inherited the number.

Reading#

butai pane read 7 --lines 50
butai pane read 7 --source screen    # the viewport, padding included
butai pane read 7 --source footer    # the band the state detector scans
butai pane read 7 --format ansi      # keep the colours

Plain text on stdout and nothing else — no header, no id — so it pipes.

The daemon runs the VT emulator, so it resolves wide graphemes and trailing blanks once, server-side, instead of every client reimplementing the cell-grid rules the framed protocol requires. A read is a query: it does not resize the pane or acknowledge its bell, which a transient framed attach would do.

--source footer shows exactly the rows butai scans to decide an agent's state, which makes "why does butai think this agent is working?" answerable from outside the daemon.

Reach. A read covers the visible screen plus about one screen of history, whatever [general] scrollback is set to. The emulator's view is one screen tall at any offset, so reaching further means walking the scrollback a screen at a time — worth doing when something asks for it, and nothing does yet. "more": true in the JSON reports the shortfall rather than pretending the rest is not there. This is a bound on the read, not on the pane: a pane scrolls back as far as [general] scrollback goes.

Sending#

butai pane send 7 "make test"       # typed, then Enter
butai pane send 7 "partial" --no-enter
butai pane send 7 --key ctrl-c

Text is delivered as a paste, so it arrives inside the agent's bracketed-paste guard rather than looking like implausibly fast typing. Input never attaches, resizes, or takes the stage.

Waiting#

butai agent send 7 "run the migration" --wait
butai agent wait 7 --until finished,exited
butai agent wait 7 --until attention --timeout 60000

States are the daemon's own: waiting, working, finished, idle, exited. Two aliases exist only in the CLI: done (finished,idle,exited) and attention (waiting,finished,exited). The default is finished,exited — notably not idle, which is also a freshly spawned agent's initial state.

The level-vs-edge trap#

butai agent send 7 … followed by butai agent wait 7 --until finished can return immediately on the previous turn's finished, because the daemon recomputes agent state on a ~2s tick and may not have noticed the new prompt yet.

butai agent send 7 … --wait avoids this: it reads the notification feed's head before it types, and then only accepts a state reached after that point. If you need the two separate, pass the same position yourself via --since-seq.

Note that finished costs a 3-second settle window by construction, so a wait that returns it has already paid for the quiet period. That is a feature.

Why polling#

wait polls GET /v1/workspaces/{ws}/agents rather than subscribing to /v1/events. Two reasons:

  • ApiEvent::Workspaces carries per-workspace counts, never per-pane state, so a subscriber would have to follow every event with a GET anyway.
  • A clean agent exit removes the row and emits no notification at all, so an edge-only waiter would hang on the most ordinary success. A vanished row is the only evidence there is.

Agent state is recomputed on the daemon's ~2s sampler tick, so polling faster than that buys nothing.

A server-side blocking route would let other embedders (Caliper, the mobile clients) skip the loop. The CLI's contract is its exit codes and its --json shape, both of which are already what such a route would return, so it can move without anything downstream changing.

Exit codes#

--quiet prints nothing on success and leaves the code as the answer.

code meaning
0 success; for wait, the state was reached
1 generic failure — daemon unreachable, 5xx
2 no such workspace, pane, or agent
3 wait timed out; the target is still running
4 the target exited, or process status found a failure
64 usage — bad flag, bad target, self-target

exited is code 4 even when you asked to wait for it: it is in the default set so the wait terminates, not because a dead agent is a success.

butai agent wait 7 -q && ./deploy.sh         # only on a live finish
butai process status -q || echo "something died"

Spawning helpers#

P=$(butai agent spawn claude --background)
butai agent send $P "summarise the failures in ./logs" --wait
butai pane read $P --lines 40

spawn prints the bare pane id, so it assigns straight into a variable, and --background leaves the stage where the human left it. Configured agent types come from [[agents]] (claude, codex, gemini, opencode, aider, agy by default); butai --json agent ls and GET /v1/agents both list them.

--prompt folds the first two lines into one, which is the shape an orchestrator wants — spawn a worker, hand it its task, block until it answers:

P=$(butai agent spawn claude --background --prompt "summarise ./logs" --wait)
butai pane read $P --lines 40

The prompt is not typed the instant the pane exists. The spawn route returns as soon as the PTY is there, but an agent CLI needs about a second more before it is reading input, and what is typed into that gap does not simply queue: the paste lands in the box, the Enter after it is dropped with the rest of the buffered startup input, and the turn never begins. So --prompt waits for the agent to draw its footer first. Still only stdout is the pane id — the wait reports itself on stderr — so P=$(…) keeps working.

There is no split#

The daemon refuses layout commands by design — the frame is fixed, with rails and one stage. You add an agent row or a process row and one of them takes the stage; you never divide a pane. Anything looking for a split verb is looking for the wrong tool.

A worked example#

# Build the CLI somewhere off the repo — an in-repo target/ corrupts on SMB.
CARGO_TARGET_DIR=/tmp/butai-target cargo build -p butai
BUTAI_SOCKET=/tmp/t/butai.sock /tmp/butai-target/debug/butai new -s smoke

# ...then, in the pane's own shell:
butai whoami && butai pane ls
P=$(butai agent spawn claude --background)
butai agent send $P "reply with the single word ready" --wait --timeout 120000
butai pane read $P --lines 5
butai agent wait $BUTAI_PANE    # refuses: exit 64

    ↑↓ move ↵ open esc close