drive kolu outside the browser · alpha
Drive kolu
from a terminal.
The canvas is one way to use kolu — the browser. But every terminal kolu owns is also reachable from outside the browser, so you can script it, automate it, or hand it to a coding agent.
Two ways in:
- MCP — connect your coding agent (Claude Code, Codex, any MCP host) to kolu, so it can create, drive, and observe terminals itself. This is how one agent drives a fleet of others.
- The
kolucommand — the same terminals from a shell: list them, create one, read a screen, send a prompt, wait for a turn to end.
The kolu command
One command carries the whole toolkit. Every verb talks to the same terminals the canvas shows, so a terminal you create from the shell appears on the canvas immediately, and one you created in the browser is drivable from the shell.
| verb | what it does |
|---|---|
kolu ls | the roster — state, repo·branch, PR, agent, and what each is running |
kolu create | open a terminal — say where it goes with --toplevel (its own tile) or --parent <id> (a split inside that tile); --worktree cuts a branch first, -- <cmd> launches an agent in it |
kolu send | type into a terminal — text, or a named key with --key |
kolu wait | block until output settles or matches, or until an agent’s turn ends |
kolu debrief | wait until a worker’s turn is over and its output is quiet, then print its screen |
kolu snapshot | read a terminal’s screen as plain text (--tail N for what’s on screen now) |
kolu screenshot | save that screen as a themed PNG instead — the colours, box drawing and highlights plain text throws away. Writes kolu-screenshot.png, or somewhere else with -o |
kolu history | the scrollback above the current screen |
kolu kill | end a terminal |
kolu watch | a live feed of terminal changes and output activity — or, with --states/--held-for/--nag, the supervision loop below |
A short driving loop — create an agent, give it a prompt, wait for its turn to end, read the reply:
id=$(kolu create --toplevel --worktree fix-parser -- claude)
kolu send "$id" "read /tmp/brief.md and take it end-to-end"
kolu wait "$id" --until idle:300 # the text landed
kolu send "$id" --key Enter # submit — its own step
kolu debrief "$id" # its turn ended, it went quiet, here's the screen
The last step used to be two more commands — wait for the turn, then read the
screen. kolu debrief is both, plus the part that is easy to forget: it also
waits for the terminal to go quiet, because an agent whose main loop ends
its turn while a background subagent is still running reports “finished” while
its work is still going. Nudging it there interrupts real work. debrief is
exactly kolu wait "$id" --until awaiting,waiting --settled 15000 --snapshot 40
— --settled and --snapshot are wait’s own flags if you want a different
window or a different condition.
Three things that make the verbs pleasant to script: stdout is data (so
id=$(kolu create …) captures exactly the id, with the human-readable trailer
on stderr), ids accept any unique prefix (kolu send 3f9c …), and the
exit codes are a contract — 0 done, 1 a usage or connection error, 2
a wait timed out, 3 the terminal exited first, 130 interrupted. Read verbs
take --json.
kolu surface — the same API as shell verbs
kolu surface <verb> … spells the same tool table kolu mcp serves a coding
agent as command-line verbs, projected by
@kolu/surface-cli: screen_text is a verb,
lifecycle_create is a verb, and list prints the whole verb table — which
dials nothing, so a dead endpoint still lists. kolu surface --help is a
page written for a person rather than an alphabetical dump of every agent’s
paragraph — the verbs grouped by what a supervisor does with them, one
example each. A verb’s whole input can ride one flag too: --input '{…}'
(or --input - from stdin), which the heaviest verb (lifecycle_create with
its placement) usually wants; --json is strictly an OUTPUT answer, on every
verb including list itself. Beside the tools ride the member readers
get / keys over the same live views (terminals, urgency, …), each
with a --follow form that streams the answer as ndjson.
kolu surface screen_text "$id" --tail 20
kolu surface lifecycle_create --input '{"placement":{"kind":"toplevel"},"run":"claude"}'
The shared endpoint flags apply unchanged — --socket, --state-root,
--host, on either side of the verb. And stdout stays data: one JSON value for
a read, ndjson lines for a --followed one. What differs is the exit-code contract — for
kolu surface the matrix is @kolu/surface-cli’s, not the native verbs’
1/2/3 above:
| code | what it means |
|---|---|
0 | the verb answered |
1 | the daemon’s refusal — typed JSON on stderr with no prefix, so a script branches on the code instead of parsing prose |
2 | usage: the input didn’t match what the verb declares |
3 | the endpoint couldn’t be reached — the message names it as spelled |
130 | interrupted |
One carve-out a driver learns ONCE: a parse-layer refusal — a typo’d verb or
a rejected flag — never reaches either face’s handler, so the CLI library
renders the usage and kolu answers 1 on BOTH faces. The surface matrix’s
2 moves the other way: the request never left this process — the face
itself rejected the input (an --input blob with no valid JSON, a key no verb
owns) before a single byte dialed. So among the surface face’s own readers:
1 is a daemon answer, 2 is not.
The full contract is the surface-cli reference.
kolu watch as a doorbell that keeps ringing
An agent terminal finishes and sits at an empty prompt. Nobody notices for hours. Every alert kolu has taps you once, when the terminal goes idle — miss that tap and the terminal is silent forever.
The flags on kolu watch turn it into the alert that doesn’t let go:
kolu watch --states waiting,awaiting --held-for 60s --nag 5m --ignore-self
Background that, and every terminal that has been idle for a minute announces itself, and keeps announcing itself every five minutes, until somebody gives it work or kills it.
| flag | what it does |
|---|---|
--states | which agent states to report — any of working, awaiting, waiting, comma-separated. awaiting is an agent blocked on you; waiting is one whose turn ended |
--held-for | report a state only once it has held that long — milliseconds, like kolu’s other windows (60000), or with a unit: 500ms, 60s, 5m, 2h, 1d. An agent handed more work inside the window is never reported at all |
--nag | re-report every interval it keeps holding, so a line you ignored comes back instead of vanishing |
--ignore-self | mute the terminal this process is running inside ($KAVAL_TERMINAL_ID). Refused if you are not in a kolu-owned PTY, and refused if the padi you are watching does not know that terminal — because the mute would then mute nobody and still report success. That covers --host, a --socket aimed at a different padi, and a stamp gone stale across a daemon restart, which re-keys terminals. Pass --ignore <id> rather than guessing |
--ignore <id> | mute this terminal (repeatable). Fail-open: a stale or unknown full id costs nothing, and every new terminal is still watched. A prefix that named nobody is still dropped, but warned on stderr |
--heartbeat | emit a timestamped alive line every interval — same duration grammar as --held-for — so a quiet pipe means nothing is matching, not the stream is dead. Omit to stay silent when nothing is happening |
Each line says why you are being told: snapshot (already in that state when you started watching), transition (it just got there and held), or nag (still there). The snapshot is what makes it safe to start watching late — you see what is already standing, not only what changes next.
14:02:11 a1b2c3d4 snapshot waiting 42m fix the parser
14:04:11 heartbeat
14:07:11 a1b2c3d4 nag waiting 47m fix the parser
14:09:40 9f01ee22 transition awaiting 1m
A heartbeat is the one line with an empty id column — that is how you skip it.
Two things it deliberately does not do. It never reads the screen — an idle
agent that repaints its prompt once a second is still idle, and kolu asks the
agent’s own state rather than guessing from output. And it is never scoped by a
list of ids: no argument means the whole fleet, because a watcher narrowed to
the terminals you remembered goes blind to the one you didn’t. Mute with
--ignore / --ignore-self instead. (A single id still works, as a debugging
tail — but tailing the one id you also muted is refused: that watch could never
match.)
--json gives the same feed as NDJSON, filtered the same way before it leaves
kolu, so a script consumes it with jq and nothing has to re-implement the
timing. Every line carries a kind — snapshot/transition/nag here, and
terminal/removed/activity on the plain change tail — so one jq switch
reads either feed. --heartbeat adds {"kind":"heartbeat","at":<epoch ms>}.
The same knobs are MCP tool parameters for a coding agent supervising a
fleet, except --heartbeat: over MCP, watch_next answers “is anything
there?” with its own timeoutMs, so a clock tick would only mix liveness into
terminal events.
create makes you say where the terminal goes
kolu create takes exactly one of --toplevel or --parent <id>, and
there is no default — neither flag, or both, is refused with the rule. That
looks fussy for one command until you remember what the parent edge means: a
terminal with a parent is drawn inside that parent’s tile, and the
Dock reads the same edge as who works for whom. A script that never
mentions placement isn’t asking for top level, it just never thought about it —
and that’s how a fleet of agents that should have been splits ends up as a row
of unrelated tiles, with nothing failing to tell you. Saying it costs one word:
kolu create --toplevel -- claude # its own tile
kolu create --parent "$KAVAL_TERMINAL_ID" -- codex # a split beside you
The same rule applies to the MCP tool an agent calls (placement is a
required field there) and to padi-tui create.
Which machine a verb talks to is one flag — --socket, --state-root, or
--host user@box for a remote host over ssh — and it can go on
either side of the verb. Inside a kolu terminal you pass none at all: the daemon
that owns you is already in the environment.