butai docs 1.3.0

The command line#

butai is one binary and it is three programs: the workbench you attach to, a client of the daemon's REST API, and the daemon itself. This page is the whole command tree — every subcommand, every flag, how a target string is resolved, what each command prints under --json, and what every exit code means. The keyboard inside the workbench is Keys; the routes underneath these commands are The butai client protocol (version 1); the surface as an agent in a pane sees it is Driving butai from inside a pane.

The invocation model#

Commands fall into three groups, and the group decides what the binary does with the socket.

group commands transport
attaching bare butai, new, attach hands off to the client, which speaks the framed protocol and owns the terminal
structured control workspace, pane, agent, process, whoami HTTP over the same socket, one connection per request
legacy one-shots ls, kill-session, kill-server the framed control path — they predate the REST face and their output is a contract the test suite drives
process modes daemon, proxy, reset, standalone no daemon conversation at all; they are the process
the odd one update HTTPS to GitHub, then the framed path for the one kill-server it ends with

Bare butai#

butai

Attaches to the daemon at the resolved socket, spawning one if none is running, and opens the workspace for the current directory if nothing is open yet. It is butai attach with no target, plus one thing attach deliberately does not do — see The ssh handoff below.

When a daemon is auto-started#

Every path that opens a socket goes through the same connect-or-spawn: try connect(2), and if that fails, fork this same executable as butai daemon with BUTAI_SOCKET pointing at the requested path, setsid, stdio to /dev/null, then retry the connect for up to about 40 attempts with a widening backoff (≈50ms rising to ≈440ms).

That applies to every command that talks to a daemon, not just the attaching ones: butai ws ls on a machine with no daemon running starts one. A shared lock file beside the socket (butai.lock) is what keeps two racing clients from both spawning — a live daemon holds it exclusively, so a client that cannot take it shared knows one is already coming up and waits instead of forking a second.

The two commands that never spawn anything are butai reset, which touches only your terminal, and butai standalone, which binds a socket of its own.

The nesting guard#

The daemon sets $BUTAI in every pane it spawns, to the socket path it actually bound. An attaching command compares that against the socket it is about to attach to, canonicalising both, and refuses if they are the same daemon:

already inside this butai (/home/you/.butai/butai.sock) — attach a different
--socket, unset BUTAI to force, or detach first

Attaching a different daemon from inside a pane is allowed on purpose — a remote workbench opened from a local pane is a different daemon drawing a different screen. butai standalone refuses any nesting at all, because it has no socket identity to compare with.

The ssh handoff#

This is what separates bare butai from an explicit attach. When bare butai runs with $SSH_CONNECTION set and both stdin and stdout are ttys, it writes a Secondary DA query (ESC[>c) to the terminal and reads the reply. butai's own terminal emulator answers with 98 (b) in the identifying field, so a reply starting ESC[>98; means the far end of this ssh session is being displayed inside a butai pane. In that case the far side writes a one-way APC naming user@<server address from $SSH_CONNECTION> and its own socket path, prints

[butai: opened in your local butai — this machine's projects are in its tab bar]

and exits — the near daemon sees the APC in the pane's output and dials back, so the remote machine's workspaces appear in the tab bar you already had.

Nothing is written before the gate passes, and the APC only ever follows a confirmed butai reply, so a plain terminal sees one invisible DA2 query and nothing else. Set BUTAI_NO_HANDOFF to a non-empty value other than 0 to skip the probe entirely. butai attach and butai new never probe: asking for those is asking for a workbench here.

Global options#

All four are global = true, so they are accepted at any depth — butai --json pane ls and butai pane ls --json are the same command.

flag type default effect
--json bool off emit JSON instead of human text
--socket <PATH> path $BUTAI_HOME's socket, else $BUTAI_SOCKET, else ~/.butai/butai.sock which daemon to talk to
-w, --ws <WS> workspace id or name $BUTAI_WORKSPACE default workspace scope
-q, --quiet bool off print nothing on success; the exit code is the answer
-h, --help print help (--help is long form, -h the summary)
-V, --version print the version — top level only

--quiet beats --json: it suppresses success output whatever the format. Errors are unaffected — they go to stderr regardless, because a silent failure is not a quiet one.

Without a home directory to resolve, ~/.butai becomes /tmp/butai-<uid>, so the socket default follows. That is why butai --json whoami is the way to learn a remote daemon's socket path rather than assuming one.

Targets#

Every command that addresses a pane takes a target with the same grammar:

TARGET := [SCOPE ":"] LEAF
SCOPE  := <workspace id> | <workspace name>
LEAF   := <pane id> | <agent or process name> | "stage"

So 4, 1:4, api:4, reviewer, api:reviewer and 1:stage are all valid. The split is on the first colon, so a name may contain one (api:build:web is workspace api, name build:web) while a scope may not. Surrounding whitespace is trimmed on both halves; an empty half (:4, 1:, :) is a usage error.

How each leaf resolves#

leaf resolution
bare pane id (7) scanned for across every open workspace — no scope needed, no scope consulted
scoped pane id (1:7) an assertion: fails if pane 7 is not in workspace 1
name (reviewer) case-insensitive substring match against every agent title and process name in the scope
stage whatever pane the scope's stage is showing

Pane ids come from one daemon-wide counter, so a bare id is already unambiguous — unlike a multiplexer that numbers panes per window. That is why butai pane read 7 works from anywhere, and why a scope alongside a numeric leaf is an assertion rather than a lookup: a script that cached an id across a workspace teardown gets an error instead of acting on whatever pane inherited the number.

Where the scope comes from#

For everything except a bare pane id, the scope is taken in this order:

  1. the target's own <workspace>: prefix,
  2. -w / --ws,
  3. $BUTAI_WORKSPACE, which every pane carries.

With none of the three, the command fails with exit 64 and says so:

no workspace given for the pane list: pass --ws, write the target as
<workspace>:<leaf>, or run inside a butai pane

A numeric scope is taken at face value. A named one is matched case-sensitively against the workspace list; no match is exit 2, more than one match is exit 64 listing the colliding ids.

Ambiguity and self-targeting#

A name that matches more than one pane is an error, not a coin flip:

2 panes in workspace 1 match "claude"; use an id: 42 (claude), 45 (claude)

Sending a prompt to the wrong agent is not recoverable, so the CLI will not guess. The same reasoning refuses a target that is the caller's own pane, for pane send, agent send, agent wait and agent kill: typing into your own pane appends to the prompt you are composing, and waiting on yourself can never return, because you are working precisely because you are running the wait. Both are exit 64.

Target resolution happens client-side. There is no /v1/resolve route today — the CLI does the lookups itself against /v1/workspaces and /v1/workspaces/{id}.

Attaching#

butai [new|attach]#

butai                       # attach; open the current directory if nothing is open
butai new [-s <SESSION>]
butai attach [-t <TARGET>]
command flag type default effect
new -s, --session <SESSION> string generated the workspace name to create and attach to
attach -t, --target <TARGET> string most recent the workspace name to attach to

"Session" here means workspace: the daemon's session list is derived from its workspaces, one per project directory.

All three forms end up in the same client, which ensures a workspace exists before drawing: a named target that is already open needs nothing; an unnamed one is satisfied by any open workspace; otherwise the client posts /v1/workspaces with the current directory (and the name, when one was given). So butai attach -t api in a directory with no api workspace open creates one here rather than failing.

On detach the client prints the reason and exits 0:

[butai: detached]

butai ls#

butai ls

No flags of its own. One line per workspace, in the daemon's own order:

my-app: 1 window (1 clients) [/home/you/Projects/my-app]
api: 1 window (0 clients) [/home/you/Projects/api]

no sessions when there are none. The window count is always 1 — the workbench has fixed rails and one stage, so there are no windows to count; the field survives because it is part of the framed reply shape.

butai kill-session -t <TARGET>#

butai kill-session -t work

-t, --target <TARGET> is required and is a workspace name, not the target grammar above and not an id. Kills the workspace and everything in it. An unknown name is a daemon-side error, reported as exit 1.

butai kill-server [--clear]#

butai kill-server           # workspaces are remembered
butai kill-server --clear   # ...and forgotten, so the next start comes up empty

Detaches every client, kills every workspace, and stops the daemon. Without --clear the open workspaces and their per-pane output dumps are snapshotted first, so the next start comes back to them. --clear removes both halves of that restore state before shutting down, so a daemon killed mid-exit still comes up empty.

butai workspace (alias ws)#

The /v1/workspaces routes on the command line. Nothing here is CLI-only.

command signature notes
ls (alias list) butai ws ls every workspace with its counts
show butai ws show [TARGET] one workspace's agents, processes and changes
create (alias new) butai ws create [--cwd PATH] [--name NAME] [--layout LAYOUT] prints the new id
rm (alias kill) butai ws rm [TARGET] closes it and kills everything in it

[TARGET] on show and rm is a workspace id or name and overrides --ws when given; with neither, the command fails.

create flags:

flag type default effect
--cwd <PATH> path the current directory directory to open
--name <NAME> string the directory's basename workspace name
--layout <LAYOUT> string none accepted and ignored

--layout reaches the daemon and is discarded there. Layout presets described pane splits, and the workbench has fixed rails; the parameter survives on the wire because shipped clients still send it. Do not build on it.

create prints the bare new id on stdout, so it assigns straight into a variable, and rm prints killed workspace 3.

ls output is one tab-separated row per workspace, mentioning only the agent states that are actually populated:

1   my-app  3 agents (1 working, 1 waiting), 2 processes, 4 changes [/home/you/Projects/my-app]

show prints a header line then AGENTS, PROCESSES and CHANGES blocks, skipping any that are empty. Staged files come first in CHANGES and are marked staged, so butai ws show answers "what would a commit take?" without a second look at git.

butai pane#

command signature
ls (alias list) butai pane ls [TARGET]
read butai pane read <TARGET> [-l N] [--source S] [--format F]
send butai pane send <TARGET> [TEXT...] [--key KEY] [--no-enter]

pane ls takes a workspace id or name (overriding --ws); read and send take a pane target.

pane ls#

One tab-separated row per addressable pane — agents first, then processes:

42  agent   working ⠐ Refactor help button to separate screen
71  agent   working ⠂ Create comprehensive documentation <- you
106 agent   idle    ✳ Claude Code [stage]

Columns are pane id, kind (agent or process), status, then the label with [stage] appended when that pane is on the stage and <- you when it is the caller's own. no panes in workspace N when there are none.

pane read#

flag type default effect
-l, --lines <LINES> usize 200 maximum rows, counting back from the live screen
--source <SOURCE> scrollback | screen | footer scrollback which band to read
--format <FORMAT> text | ansi text ansi keeps colors as SGR sequences
  • scrollback — recent history ending at the live screen.
  • screen — exactly the visible viewport, blank rows and all.
  • footer — the band the agent-state detector scans, which makes "why does butai think this agent is working?" answerable from outside the daemon.

Human output is the lines and nothing else — no header, no pane id, no color — so butai pane read 7 | grep … behaves like reading a file. The read is a query: it does not resize the pane, take the stage, or acknowledge its bell.

pane send#

flag type default effect
--key <KEY> key name none send one named key instead of text
--no-enter bool off do not press Enter after the text

[TEXT]... is joined with single spaces and delivered as one paste, not a keystroke per character: it is a single round-trip, and it arrives inside the agent's bracketed-paste guard the way a real paste would rather than looking like implausibly fast typing. Enter follows unless --no-enter.

--key and text are mutually exclusive (exit 64 either way — both given, or neither). Key names are trimmed and lowercased before matching:

accepted
enter, esc, tab, backspace, delete
up, down, left, right
home, end, page_up, page_down
ctrl-<char> or ctrl+<char>, a single character

Anything else is exit 64 naming what is accepted, rather than a silent no-op keystroke.

send prints nothing on success, under --json too.

butai agent#

command signature
ls (alias list) butai agent ls [TARGET]
spawn butai agent spawn <KIND> [--background] [--prompt TEXT] [--wait] [--timeout MS]
send butai agent send <TARGET> [TEXT...] [--wait] [--until SET] [--timeout MS]
read butai agent read <TARGET> [-l N] [--source S] [--format F]
wait butai agent wait <TARGET> [--until SET] [--timeout MS] [--since-seq N]
kill butai agent kill <TARGET>

agent ls and agent read are the same code as their pane counterparts — agent ls therefore lists processes as well as agents, exactly as pane ls does. GET /v1/agents is the route that lists configured agent types; the CLI has no verb for it.

agent spawn#

flag type default effect
--background bool off do not take the stage — leave the human's view where it is
--prompt <TEXT> string none send this prompt once the agent is up
--wait bool off with --prompt, block until the agent finishes it
--timeout <MS> u64 300000 give up after this many milliseconds when waiting

<KIND> is an agent type as configured under [[agents]] — see Configuration. The workspace comes from --ws or $BUTAI_WORKSPACE only; there is no positional workspace argument.

stdout is the bare pane id and nothing else, so P=$(butai agent spawn claude) works. Under --json it is {"pane":42}. Everything the spawn has to say afterwards goes to stderr.

The spawn route answers {"ok":true} rather than the new id, so the CLI recovers it by listing the agents before and after and taking the highest id that was not there before — unambiguous, because pane ids come from one daemon-wide counter.

--prompt does not type the instant the pane exists. The 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 queue: the paste lands in the box and the Enter after it is dropped with the rest of the buffered startup input, so the turn never begins. So --prompt first polls the pane's footer every 100ms for up to 15 seconds, waiting for a non-blank row — a TUI paints because its input loop is running. If it never draws, the prompt is sent anyway and a warning goes to stderr:

pane 42 drew nothing in 15000ms; sending the prompt anyway

--wait has effect only alongside --prompt; on its own it is silently a no-op, as is --timeout with no wait. The wait reads the notification feed's head before posting the spawn, so it is edge-correct by construction, and it uses the default --until set (finished,exited) — spawn has no --until of its own. Its outcome reaches you through the exit code, plus a stderr line if it timed out.

agent send#

flag type default effect
--wait bool off block until the agent reaches --until
--until <SET> state set finished,exited states that end the wait
--timeout <MS> u64 300000 give up after this many milliseconds

[TEXT]... is the prompt, joined with spaces; empty is exit 64. Delivery is the same paste-then-Enter as pane send, with no --no-enter escape — this verb submits a turn.

--until is validated even without --wait, so a typo is caught either way. With --wait, the notification feed's head is read before the prompt is injected, which is what makes the wait edge-correct rather than level-triggered (see below).

agent wait#

flag type default effect
--until <SET> state set finished,exited states that end the wait
--timeout <MS> u64 300000 give up after this many milliseconds
--since-seq <N> u64 none only accept a state reached after this notification sequence

The state set is comma-separated, trimmed, case-insensitive and deduplicated. Five names are the daemon's own, two are aliases that exist only in the CLI:

word expands to
waiting blocked on you mid-task
working recent output
finished finished its turn and settled at the prompt
idle quiet, nothing pending
exited the process is gone
done finished, idle, exited
attention waiting, finished, exited

An unknown word, or a --until that names no states at all, is exit 64 listing the real ones. The default is deliberately not idle: a freshly spawned agent starts out idle, so waiting for it would return immediately.

wait polls GET /v1/workspaces/{ws}/agents — 400ms, doubling to a 1s ceiling. It is not the event stream for two reasons: ApiEvent::Workspaces carries only per-workspace counts, so a subscriber would still have to follow every event with a GET; and 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 evidence, and that is what wait reports as exited. Agent state is recomputed on the daemon's ~2s sampler tick, so polling faster buys nothing.

The level-vs-edge trap. butai agent send 7 … followed by a separate butai agent wait 7 --until finished can return immediately on the previous turn's finished, because the daemon may not have noticed the new prompt yet. --since-seq N fixes it: a matching state then counts only once the daemon has emitted a notification for this pane past sequence N, or the state has changed since the first poll (which covers idle, the one state that never notifies). agent send --wait and agent spawn --prompt --wait do this for you.

A bare numeric target that resolves to nothing is reported as exited (code 4) rather than not-found (code 2): an agent that has already gone is exited, and a wait that spanned the exit has always said so, so starting half a second later on the same situation must not report a different code. Only a bare id gets this — 1:7, stage and a name can each fail for reasons of their own, and stay 404s.

Output is one line, or the outcome object under --json:

pane 42 finished
pane 42 still working after 300000ms

agent kill#

Deletes the pane. Refuses your own pane. Prints nothing.

butai process (alias proc)#

command signature
ls (alias list) butai process ls [TARGET]
status butai process status [TARGET]
start butai process start <NAME> [COMMAND...]

[TARGET] is a workspace id or name, overriding --ws. Rows are tab-separated — pane, status, name, command:

44  ok  dev npm run dev
59  FAIL(1) build   cargo build

status prints the same rows and exists to be used in a condition: it exits 4 if any process's status starts with FAIL or carries a non-zero exit code, so

butai process status -q || butai agent send 7 "the dev server died"

works without parsing anything.

start takes a label for the process rail and the command, joined with spaces. An empty command is exit 64 from the CLI — note that the route itself treats an empty command as "the workspace's default shell", so POST /v1/workspaces/{id}/processes can do something the CLI will not. start prints nothing on success.

butai whoami#

butai whoami
# pane 71
# workspace 1
# socket /home/you/.butai/butai.sock

Answers "where am I?" for a program running inside a pane, and is the first thing a caller should run before issuing any control command. Outside a pane:

not inside a butai pane ($BUTAI_PANE is unset)
socket /home/you/.butai/butai.sock

It contacts no daemon. The socket it reports is the resolved socket this invocation would talk to, not the raw $BUTAI_SOCKET, so it is always answered — which makes ssh host butai --json whoami the way to learn a remote daemon's socket path. ssh -L needs that path and cannot guess it: it forwards the path verbatim without shell expansion, and ~/.butai/butai.sock is not guaranteed anyway. See Remote machines.

butai update#

butai update           # check, ask, install, restart the daemon onto it
butai update --check   # report what is available and change nothing
butai update --yes     # install without asking (implied by --quiet)
butai update --daemon  # make the daemon on --socket update *itself*

The deliberate form of the question the workbench raises at launch. Both end in the same place; this one is for a shell prompt, and differs in four ways.

  • It ignores [update] declined_version. Typing the command is changing your mind, so a release you turned down is offered again.
  • It reports what a launch check swallows. No network, no build published for this target, an install directory owned by root — somebody is waiting on an answer here, where a client starting up should just start up.
  • A no is only "not now". It writes nothing. The workbench's no is remembered for that version; this one is not, because you can just run it again.
  • It does not open a workbench. After swapping the binary, it starts the daemon on the new build. If that restart fails, it reports the failure; the next butai command can start the daemon.

It follows [update] channel from the config of this install — stable by default, or dev for the prereleases cut from develop. --daemon follows the channel configured on the far machine instead, since that is the install being replaced. See Configuration.

butai 1.1.0 is available — you have 1.0.0
  butai-1.1.0-x86_64-unknown-linux-musl.tar.gz
  into /home/you/.local/bin/butai
install it? [y/N] y
downloading butai-1.1.0-x86_64-unknown-linux-musl.tar.gz
stopping the daemon (your workspaces are saved and come back)
starting the daemon on the new version
updated 1.0.0 -> 1.1.0

Which artifact it takes is decided at compile time, not runtime. A release publishes platform-specific tarballs, and crates/butai-update/build.rs bakes this build's own target triple in — so a musl build asks for the musl tarball because it is the musl build. scripts/install.sh has to guess with uname and ldd --version; it runs before any butai exists and has no better option. A release with no artifact for this triple is reported, never approximated.

The download is checked against the release's SHA256SUMS before anything is executed, and the binary is replaced by a rename in its own directory, so there is no moment where a half-written file is on your PATH.

Stopping the daemon is not destructive, and the order matters. It is the same snapshot butai kill-server takes — the open workspaces, their agents, and every pane's output — written before anything is torn down, and restored by the build that comes up next. The daemon is stopped before the swap rather than after, because a daemon is located through /proc/self/exe, which reads ".../butai (deleted)" once the file underneath it has been replaced.

Not a terminal and no --yes is an error rather than a silent install:

error: not a terminal — pass --yes to install without asking

It refuses to run from inside a butai pane, which is the one place it cannot finish. Stopping the daemon kills every workspace, and one of those panes is the one you are typing in — so the command dies between staging the new binary and putting it in place, leaving the daemon stopped, the binary unchanged, and nothing running to start another. Use --daemon from in there instead: the daemon updates itself, and the pane comes back with it. --check is allowed anywhere.

Under --json, an available-update check emits current, latest, target, asset, install_path, and update_available. When already current, the reply contains current, target, and update_available: false. An installation first emits the available-update result, then a second JSON result containing updated_to, from, and daemon_restarted. A failed daemon restart is reported on stderr and by daemon_restarted: false; the binary has still been updated.

In-place self-update is unavailable on Windows. Install Windows updates manually; a Windows client can still request an update of a connected Unix daemon. See Native Windows TUI (experimental alpha).

Everything about when it asks on its own — the launch check, the six-hourly one, and the two answers — is [update] in Configuration. Inside the workbench, :update opens the same question.

--daemon: updating a butai you are not on#

Everything above updates this binary. --daemon updates the daemon on --socket/$BUTAI_SOCKET instead, by asking it to do the whole job itself — check, download, verify, swap, restart:

butai --socket ~/.butai/forwarded.sock update --daemon
ssh workhorse butai update --daemon --yes

This is the case the local updater cannot reach. A workbench attached to a daemon on another machine is two butais, and updating the one in front of you leaves the one doing the work exactly as it was — the version skew the updater exists to end. Inside the workbench, :update on a tab from another machine raises this question instead of the local one, for the same reason.

It is also the answer from inside a butai pane, on any machine. Plain butai update cannot finish there — the daemon it stops owns the pane it is running in — but this can, because the process doing the work is the daemon itself: it answers, stops, swaps and execs into the new build, and the session comes back around you.

It has to be allowed on the far side: [update] allow_remote = true in that machine's ~/.butai/config.toml. Off by default, because the socket's only access control is the 0700 on its directory, and "can reach the daemon" is a weaker claim than "may replace the program this machine runs". An unconfigured daemon answers 400 and names the key.

There is no --daemon --check: it is a clap conflict, and deliberately. A check the daemon answers and something else acts on is a version that can change in between, so the daemon takes one request and reports what it did. That is also why the confirmation names the socket rather than a version — nothing on this side knows one yet.

this updates the daemon on /home/you/.butai/butai.sock, not this binary
it will restart, dropping attached clients. go ahead? [y/N] y
asking the daemon on /home/you/.butai/butai.sock to update itself
daemon updating 1.0.0 -> 1.1.0
it is restarting; your workspaces are saved and come back

The restart is an ordinary kill-server: workspaces are snapshotted before anything is torn down and restored by the build that comes up. Attached clients are detached with the same reason any shutdown uses, so a workbench keeps the cells it had and reconnects rather than blanking the screen.

Under --json, the daemon's own UpdateDto: current, latest, updating.

Process modes#

butai daemon#

Runs the daemon in the foreground on the resolved socket. Normally spawned automatically; run it by hand to watch it, or under a supervisor.

It creates the socket's parent directory and chmods it 0700, takes an exclusive flock on butai.lock as its single-instance guard — a second one exits with another butai daemon is already running — removes any stale socket file, binds, and loads ~/.butai/config.toml, printing each config warning as a log line. Logs go to ~/.butai/logs/daemon.log, rotated daily, never to the terminal; RUST_LOG sets the filter and defaults to info. On exit it removes the socket and releases the lock.

butai proxy#

ssh host butai proxy

Bridges stdin/stdout to the daemon socket, connect-or-spawning it first. This is the remote-access path: ssh provides both the transport and the authentication, and the daemon never listens on TCP. Both protocols ride it — the length-prefixed framed protocol and HTTP — because the daemon tells them apart by their first byte.

When stdin ends the socket is half-closed rather than torn down, so a one-shot butai proxy < request still gets to read its reply. The bridge ends when the daemon closes: for HTTP that is the response being complete, for the framed protocol it is the session ending.

butai reset#

Puts a terminal left in mouse or raw mode by a killed or crashed butai back to normal — the fix when your shell is spewing mouse codes. It writes the restore sequences first (so the mouse goes quiet even if the termios work fails), applies the equivalent of stty sane in place, and flushes the input queue of mouse reports already sitting in it. It uses TCSANOW rather than TCSADRAIN, so it cannot hang on a wedged terminal.

It stands alone on purpose: no nesting guard, no daemon, no socket — so it works from whatever shell the wedged terminal left you in. It needs a tty, and says so if it does not have one.

butai standalone#

A daemon and a workbench in one process lifetime, on a socket nobody else can find. No detach support, and the session is deliberately not persisted, so the next one starts empty rather than reopening whatever this one had.

The socket lives in a 0700 directory named for the process id, under $XDG_RUNTIME_DIR when set and the system temp directory otherwise, and the directory is removed on the way out. The workspace it opens is named standalone. It ignores --socket.

butai help#

butai help [COMMAND] is clap's own, equivalent to --help on that command. --help and --version exit 0; any other parse failure exits 64 rather than clap's own 2, so it matches the rest of the CLI.

Machine-readable output#

--json has two behaviours, and which one you get depends on where the data came from.

Passed through verbatim. For commands whose answer is a daemon response body, --json re-emits those bytes unmodified — not re-serialized from a parsed struct — so the CLI's JSON is the REST API's JSON and the two cannot drift as DTOs gain fields. A trailing newline is added if the body lacks one.

command body
ws ls WorkspaceSummary[]
ws show WorkspaceDetail
ws create {"id":3}
ws rm {"ok":true}
pane read, agent read PaneOutputDto
process ls, process status ProcessDto[]

Serialized by the CLI. These have no route behind them, so the CLI owns the shape:

command shape
ls SessionInfo[] — {id, name, windows, attached_clients, cwd}
whoami {inside_butai, pane, workspace, socket}
pane ls, agent ls {pane, kind, label, status, staged}[]
agent spawn {"pane":42}
agent wait, agent send --wait {pane, state, exited, timed_out, waited_ms}

The wait outcome is shaped so a future server-side blocking route can return exactly this and the --json output does not change when it switches over.

Commands that print nothing on success print nothing under --json either: pane send, agent kill, process start, kill-session, kill-server.

The DTO field-by-field reference is The butai client protocol (version 1); a worked client is ../web/README.md.

Exit codes#

The exit code is the interface. Under --quiet nothing is printed at all, so butai agent wait 7 -q && ./deploy.sh has to be able to tell "it finished" from "it timed out" from "there is no pane 7" — which is why failures are not all collapsed to 1.

code name meaning
0 OK success; for wait, the target reached the state
1 FAILED generic failure — daemon unreachable, a 5xx, an unexpected reply
2 NOT_FOUND no such workspace, pane, or agent
3 TIMED_OUT wait timed out; the target is still running
4 EXITED the target exited, or process status found a failure
64 USAGE bad flag, bad target, ambiguous name, self-target (EX_USAGE)

Codes 2 and 64 come straight from the daemon's own 404 and 400 when the failure happened there, and are produced client-side with the same meanings when the CLI resolved the target itself — so butai pane read 9999 and butai pane read 1:9999 agree. The mapping walks the whole error chain, so a status wrapped in context still reports what the daemon said.

exited is code 4 even when you asked to wait for it. It is in the default --until set so the wait terminates, not because a dead agent is a success: butai agent wait 7 -q && ./deploy.sh must not deploy because the agent's process fell over.

Environment#

Read by the binary#

variable read by effect
BUTAI_HOME the socket-path helper moves the whole ~/.butai tree, socket included — and outranks BUTAI_SOCKET
BUTAI_SOCKET the socket-path helper which daemon to talk to
BUTAI_WORKSPACE --ws default, whoami default workspace scope
BUTAI_PANE target resolution, whoami the caller's own pane; its absence is the test for "not inside butai"
BUTAI the nesting guard socket of the daemon this pane belongs to
BUTAI_NO_HANDOFF bare butai non-empty and not 0 disables the ssh handoff probe
BUTAI_SESSION_FILE the daemon overrides ~/.butai/session.json, and moves the per-pane dumps beside it
BUTAI_THEME_DIR the client overrides ~/.butai/themes
SSH_CONNECTION the handoff gates the probe, and its third field is the dial-back address
USER the handoff the user half of the user@host dial-back hint
XDG_RUNTIME_DIR standalone where the private socket directory goes; falls back to the temp dir
SHELL the daemon default shell for panes, when config does not name one
RUST_LOG the daemon log filter; defaults to info
TMUX the client send clipboard writes through tmux's DCS passthrough as well
DISPLAY, WAYLAND_DISPLAY the client, Linux only absent means there is no clipboard to read
HOME everything resolves ~/.butai; without it, /tmp/butai-<uid>

Set by the daemon in every pane#

Agent, process and plain shell alike:

variable value
BUTAI the socket this daemon bound
BUTAI_SOCKET the same path
BUTAI_PANE this pane's id
BUTAI_WORKSPACE the workspace it belongs to
TERM xterm-256color
COLORTERM truecolor
PATH repaired when the daemon's inherited one is missing the usual directories

[[agents]] env entries are applied after these, so an agent's own config can override any of them. Because --ws defaults to $BUTAI_WORKSPACE and --socket falls back to $BUTAI_SOCKET, a command run inside a pane already acts on its own workspace, on its own daemon, with nothing configured.

$BUTAI_HOME outranks $BUTAI_SOCKET, which is the one bit of that order worth knowing. $BUTAI_SOCKET is exported into every pane, so a command run inside butai always has one whether or not anybody wanted it; $BUTAI_HOME only ever appears because somebody typed it. So BUTAI_HOME=~/.butai-dev butai, run in a pane of your ordinary butai, reaches the dev daemon rather than the one drawing the pane. --socket still beats both — it was typed for this command in particular.

Set on the spawned daemon#

An auto-spawned daemon is started with BUTAI_SOCKET set to the socket the client asked for, which is how a non-default socket propagates without a flag. BUTAI_HOME needs no such handling: the daemon is a child, so it inherits the one the client was resolving against, and the two cannot disagree.

Recipes#

Spawn a helper, hand it a task, block, read the answer:

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

The same thing in one command — stdout is still only the pane id:

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

Gate a deployment on a live finish, not on a crash:

if butai agent wait 7 -q; then
  ./deploy.sh
else
  case $? in
    3) echo "still running" ;;
    4) echo "the agent died" ;;
    *) echo "could not wait on it" ;;
  esac
fi

Which projects need a human, as JSON:

butai ws ls --json | jq -r '.[] | select(.waiting > 0) | .name'

Watch for a failed process from inside a pane and tell an agent about it:

butai process status -q || butai agent send reviewer "the dev server died"

Read exactly what the state detector sees, when an agent's status looks wrong:

butai pane read 42 --source footer --lines 8

Interrupt a runaway program without attaching:

butai pane send 42 --key ctrl-c

Open a project without attaching to it, then start its dev server:

WS=$(butai ws create --cwd ~/Projects/api --name api)
butai --ws "$WS" process start dev npm run dev

Learn a remote daemon's socket path, then forward it:

SOCK=$(ssh host butai --json whoami | jq -r .socket)
ssh -N -L "/tmp/host.sock:$SOCK" host &
butai --socket /tmp/host.sock ws ls

Check whether you are inside butai at all before doing any of this:

if [ -n "$BUTAI_PANE" ]; then butai pane ls; fi

What the CLI does not cover#

The REST API is wider than the command tree. There is no verb for staging a pane (POST /v1/workspaces/{id}/show), restarting a process (POST …/processes/{pane}/restart), listing configured agent types (GET /v1/agents), the notification feed (GET /v1/notifications), the event stream, the file tree, or any of the git operations. Reach those with curl --unix-socket, or through a client — The butai client protocol (version 1) has the full route list.

There is also 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.

    ↑↓ move ↵ open esc close