Agents and automation
Use the read-act-wait loop to inspect and drive shared terminals, then go deeper only when you need exact CLI or JSON contracts.
Start with the operating loop. Open exact contracts only when your integration needs them.
0. The thesis: structured agent state is a projection
phux does not own terminal semantics; libghostty does, and both ends of the wire run that engine (ADR-0013). It follows that any structured view of a terminal — a cell grid, an OSC-133 command-boundary stream, a command’s captured output — is computed by a consumer from the engine it already has, not transmitted as a second model on the wire (ADR-0030).
So the agent contract is not a structured wire protocol. It is this CLI and
the versioned JSON schemas its --json verbs emit. The wire carries opaque
terminal bytes plus lifecycle and metadata; the structured shapes below are a
local projection an agent reads through the CLI
(ADR-0022: agents are a projection, the
CLI plus JSON schema is the contract). An agent that wants to own its own
projection — run the engine and read its grid directly — should copy
phux-web, the reference carry-your-own-engine consumer
(ADR-0030 §4).
The live wire does expose agent affordances: GET_SCREEN, ROUTE_INPUT,
GET_TERMINAL_STATE, SUBSCRIBE_TERMINAL_EVENTS, and an AgentEvent push
frame, documented in ../spec/L1.md. Read those as
engine-convenience snapshots over the shared engine — a convenience for
consumers that have not adopted the carry-your-own-engine pattern — not a
normative structured contract and not a license to add new structured wire
surface (ADR-0030 §2).
1. What this is, what this isn’t
This document is the agent-facing CLI surface, parallel to the
TUI’s product surface and the MCP adapter. The TUI
projects the source-of-truth Terminal to VT bytes (it renders, like tmux);
agents project it to structured data — cells, OSC-133 marks, command results.
The agent surfaces nest:
- This CLI is the canonical, stable agent contract: the verbs and their
--jsonshapes are what an agent depends on. mcp.mdis a thin adapter that wraps the samephux-clientfunctions name-for-name over JSON-RPC stdio.sdk.mddocumentsphux-clientitself — the library crate the CLI and MCP adapter are both built from. It exists today; it is L1-shaped and follows the same projection pattern.
All three are unprivileged consumers
(ADR-0017); none holds a
protocol-level privilege. The wire underneath stays additive and versioned,
normative under ../spec/. Installing the MCP companion does not make its
tools visible to a host; see Registering with a host
for the Claude Code command and generic stdio configuration.
The selector grammar is owned by tui.md §3; this file links there
rather than restating the table (the doc system’s one-fact-one-home rule). The
decision rationale lives in
ADR-0022; client-side selector resolution
in ADR-0021.
Viewport-safe against a live pane. snapshot, run, send-keys,
paste, wait, agent wait, and agent send-keys neither attach nor resize
the target pane: reads issue GET_SCREEN or GET_METADATA, and input rides
ROUTE_INPUT to a pane id. snapshot/wait/agent wait are
side-effect-free; run/send-keys/paste/agent send-keys deliberately
mutate the live PTY. snapshot --tail and --unwrap, and wait --tail /
--regex / --output-only, are client-side projections over that same read
and change nothing about it. None changes an attached human’s local focus or
viewport. resize is
the deliberate exception — changing the grid is its whole job — but it too
never attaches, so it cannot drag a pane toward the 80x24 size a caller with
no TTY would otherwise report.
5. The read-act-wait loop and exit-code mirroring
5.1 The loop
The single-pane pattern is read → act → wait → read: snapshot the pane, send
input or run a command, wait for the result to land, snapshot again. Every wait
must carry a finite timeout; a CLI watch is an unbounded stream and must run
under a child-process deadline. A worked example in sh:
phux send-keys build "cargo test" Enter
phux wait --until "test result:" --timeout 120 build
phux snapshot --json --scrollback 200 build > out.json
When you only want a command’s exit code and output, the one-shot phux run is
the higher-level alternative — it brackets the command with sentinels and
mirrors $?:
phux run --json build "cargo test"
The contrast: run is “I want the exit code”; send-keys plus wait is “I am
driving an interactive or long-lived program.” Because run mirrors the
child’s code (§5.2), phux run ... && next composes like a shell
(ADR-0022 §3).
Supervising another agent is the same loop with a different wait. A pane running an agent has a lifecycle record, not a sentinel, so wait on the record:
phux agent prompt --expect-agent reviewer --wait \
--until idle --until blocked --timeout 900 --json @7 "review the diff"
phux snapshot --json --tail 200 --unwrap @7 > transcript.json
Read agent prompt’s answer, do not assume it. Exit 0 means a transition into
one of those states was observed; 124 means none was, which is not the
same statement as “the agent is still working” — check phux agent show for
the level and edge/baseline in the --json document for what the wait
actually saw. Exit 1 is a departure: the record went away mid-wait, or its
state was withdrawn to unknown because the pane’s occupant died or
changed. Neither must ever be read as completion. Why the verb refuses to
answer from the current level at all is in §2 and, normatively, in
../spec/L3.md §3.7.
When the input itself is a block of text — a heredoc body, an indented code
snippet for a REPL, a multiline SQL statement — use paste, then submit
explicitly:
phux paste repl "$(cat snippet.py)"
phux send-keys repl Enter
send-keys without a trailing Enter would type the block character by
character, letting the REPL’s auto-indent mangle every indented line; paste
delivers it as one bracketed block (when the pane’s program supports DEC mode
2004) and the program inserts it verbatim, waiting for the explicit Enter.
The fleet extension is discover → create → place → shape → act → observe →
surface asks → verify. See the executable
examples/agents/orchestrate-placed-fleet:
it launches/spawns with explicit placement, serializes topology edits, watches
agent panes concurrently under hard bounds, and prints C-a q / C-a Q human
guidance without changing focus.
5.2 Exit-code mirroring
Exit codes are not uniform across verbs:
| Verb | Exit codes |
|---|---|
ls | 0 ok; 1 no server / unexpected result. |
snapshot | 0 ok; 1 failure (no server, serialize error, resolve miss). |
send-keys | 0 ok; 1 failure (no server / refused / miss). |
paste | 0 ok (including a paste the pane’s untrusted policy silently dropped); 1 failure (no server / refused / miss / unreadable stdin). |
ask | 0 accepted; 1 no server, unknown pane, or invalid ask payload. |
agent | 0 ok; 1 no server, unknown pane, or JSON render failure; 3 the miss is not trustworthy — see below (show/explain/set/clear; list enumerates and stays 0; wait and send-keys have their own rows and keep 1). |
run | the child’s own code clamped to 0..=255 (negative or >255 saturate to 255); 125 when phux gave up waiting for the sentinel (--timeout); 1 for no server / refused target / other. |
wait | 0 condition met; 124 on --timeout; 2 usage — an invalid --regex, or --until combined with --regex (clap’s own status, raised before any poll); 1 no server / parse / read error. |
agent wait | 0 a transition into a --until state was observed; 124 on --timeout, including a pane that held a target state for the whole wait; 2 the pane declares no record, an unknown --until word, or a satellite target (satellite_target — phux.agent/v1 does not federate); 1 the agent departed mid-wait (record deleted or state withdrawn to unknown), no server, or transport. |
agent send-keys | 0 the acknowledged batch reached the kernel tty queue; 2 a refusal before any byte was written; 1 transport, selector, or indeterminate delivery. Do not resend delivery_unknown. |
agent prompt | 0 delivered (and, with --wait, a target transition observed); 124 delivered but no target transition observed before timeout; 2 usage, identity, capability, or pre-write refusal; 1 transport or indeterminate delivery. |
agent answer | 0 the exact live ask was validated and the answer delivered; 2 stale/unidentified ask, invalid choice/text, or pre-write refusal; 1 transport or indeterminate delivery. |
agent start | 0 submitted, and ready unless --no-wait; 124 command typed but readiness not observed; 2 invalid name/kind/argv, no manifest, or unsafe target; 1 launch, transport, or observation failure. |
new | 0 ok; 1 duplicate -s name / failure. |
resize | 0 the pane holds the requested geometry; 1 no server / selector miss / unknown pane, or the server holds a different size (an attached view’s window-size policy owns it); 2 unusable COLSxROWS (clap usage error, raised before any connection). |
rename | 0 renamed; 1 no server or transport failure; 2 unknown source session or destination name already exists. |
launch / spawn | 0 spawned/resolved/listed; 1 invalid integration, placement, server, or spawn failure. |
watch | 0 Ctrl-C, plain EOF, or an --until match; 124 --timeout; 2 unknown event name; 1 transport or EOF before a requested event. |
rec | 0 ok, including a capture ended by Ctrl-C; 1 no server, unresolvable target, unknown output extension, unreadable --from file, or write/encode failure. |
plugin | 0 ok; 1 invalid/missing manifest, invalid config, refused registry write, or unknown plugin id. |
workspace | 0 ok; 1 missing git repo, invalid git output, no server for save/restore, invalid archive, or JSON render failure. |
satellite | 0 ok; 1 invalid name/endpoint, duplicate configured name, invalid config, refused registry write, or unknown satellite name. |
insert-pane / move-pane / swap-pane | 0 ok; 1 transport failure; 2 selector, ratio, session, or layout refusal. |
kill | 0 ok; 1 selector miss / no server / parse; 2 server-side refusal; 3 the miss is not trustworthy — see below. |
tag | 0 ok; 1 selector miss / no server / invalid target; 3 the miss is not trustworthy — see below. |
Exit 3 — “I could not answer, because I could not see all of the fleet.”
A federation hub that cannot reach a satellite still answers GET_STATE, with
that satellite’s panes simply missing from the merge. Every TARGET selector
is a search over those panes, so a search that finds nothing has two causes
that must not share a sentence or a status: the target does not exist (1,
no such target: X), or the server could not look where it lives (3, a
message naming the unreachable satellite and containing neither the words
“no such target” nor any claim of absence). Retrying is the right response to
3 and the wrong response to 1 — that is why they are different numbers.
kill, tag, and agent show/explain/set/clear return it.
Every target-resolving verb prints the distinguished message, but not all
of them can spend a status on it. snapshot, send-keys, paste, run,
wait, watch, resize, signal, rec, ask, agent wait, and
agent send-keys, agent prompt, and agent answer share one resolver, and
some of them have already spoken for the number: run mirrors the child’s own
exit code (a command may legitimately exit 3), wait and agent wait own
124, and agent wait / agent send-keys spend 2 on their own refusals.
Those verbs keep 1 and say it in words. Branch on the status where the table below
offers 3; otherwise read stderr, which never claims absence it cannot
Verbs that resolve a session name never return 3: a satellite’s
sessions and windows lists are discarded during the merge (their ids would
collide with the hub’s), so the session name space is complete even when the
fleet is not. rename therefore keeps its confident 2 for an unknown
session, and warns on stderr about the outage without changing its answer.
Verbs that enumerate (ls, agent list) warn on stderr and exit 0; under
--json, ls reports it structurally in unreachable (§4.1) because a
--json consumer does not read stderr.
Why run uses 125, not 124. run mirrors the child’s own code into
0..=255, and 124 is a code real commands produce — notably GNU timeout.
So run reserves 125 (the wrapper-failure convention, as used by env and
timeout) for “phux itself gave up,” keeping it distinct from a child that
legitimately exited 124. wait, which wraps nothing, uses 124 for its own
timeout. kill is a control-plane verb (not strictly an agent read) but shares
TARGET; its 0/1/2 triad is listed for completeness.
5.3 The JSON error contract
Every core server-talking verb above
(ls / snapshot / wait / run / watch / resize / spawn / launch /
play / rec / new / ask, plus the spatial edits of §4.12), and every
--json-bearing registry and inspection verb (tag, plugin,
remote list, satellite, worktree list, workspace inspect,
config check, logs, doctor, agent explain --file, agent wait,
agent send-keys, agent prompt, agent answer, agent start), reports a
--json failure the same way:
stdout stays empty (the document channel never carries half a result)
and stderr carries one line of JSON (ADR-0065 §4):
{
"schema_version": 1,
"error": { "code": "no_server", "message": "no server running at /run/phux.sock" },
"remedy": "start one with `phux` (attaches, auto-starting a server) or `phux server`; ...",
"exit_code": 1
}
schema_versionis1; new fields are additive and do not bump it.error.codeis a closed vocabulary owned in one place bycommands/json_err.rs; branch on it, never onmessagetext. The transport family:no_server(nothing listening at the socket),server_disconnected(the server went away mid-command),transport(any other transport/protocol failure). The resolution family:no_such_target(a miss against a complete view) andpartial_view(a miss against an incomplete fleet — the target may exist on an unreachable satellite; retry, per §5.2’s exit-3 discussion). Spatial edits add the codes listed in §4.12. The registry family:registry(a local[[plugins]]/[[remote]]/[[satellites]]config-registry read, validate, or write failed),workspace(a git workspace/worktree operation failed — not a repository, git failed, or its output did not parse),invalid_config(config checkcould not run at all — unreadable file or malformed TOML; exit 2, mirroring its prose path’s distinct “could not check” status), andjson_serialize(a result document failed to render — a phux bug worth filing). The update family (phux update):update_invalid_tagandupdate_unsupported_platform(exit 2 — a tag that is notvX.Y.Z, or a platform with no published artifact);update_source_unsupported,update_immutable_store, andupdate_package_managed(exit 2 — phux will not write to this install, andremedycarries the exact native command);update_fetch_failed,update_checksum_invalid,update_checksum_mismatch,update_archive_rejected,update_install_failed, andupdate_no_backup(exit 1). Aupdate_checksum_mismatchmeans the published digest and the downloaded bytes disagreed: nothing was unpacked and nothing was installed. The offline-explain family (phux agent explain --file, which talks to no server and so reaches none of the transport codes):capture_unreadable(the file or stdin could not be read) andcapture_invalid(the bytes are not a screen — JSON that is not aScreenState, or a capture with no rows), both exit 1; andunknown_agent_kind(exit 2 —--kindwas omitted or names no loaded detection manifest, with the roster inremedy). The agent-lifecycle family (agent wait,agent send-keys,agent prompt,agent answer,agent start):no_agent_record(exit 2 — the pane declares nophux.agent/v1record, so there is no lifecycle to wait on and no identity to verify against),satellite_target(exit 2 — the pane belongs to a federation satellite;phux.agent/v1is hub-local and does not cross a satellite link, so a hub can neither observe nor write it. Refused as soon as the selector resolves, so nothing was read and nothing was typed. Run the verb against the satellite’s own server. This is notno_agent_record: the remote pane may well have a live agent),agent_departed(exit 1 — the record was deleted or its state withdrew tounknownmid-wait; a departure, never a completion),agent_mismatch(exit 2 — the pane hosts a different agent than--expect-agent/--expect-kindnamed, and nothing was written), andinvalid_key_spec(exit 2 — a key argument would not translate to the key you clearly meant, refused before the connection is opened so the batch stays all-or-nothing). Acknowledged input addsinput_busy(nothing written; retry is safe),input_not_written(exit 1 — nothing was written, proven at some point other than lane contention: no PTY, a writer-side queue full or closed, or the pane’s own actor gone before handoff; retry is safe, under the same operation id or a fresh one, because nothing already written could be duplicated),delivery_unknown(exit 1 — indeterminate; never resend under any id — do not confuse the two:input_not_writtenis the case the server can rule out delivery for,delivery_unknownis the case it cannot),input_too_large,input_lease_held,canonical_limit_exceeded,unsafe_paste,invalid_input_batch, andpermission_denied. Ask validation addsno_active_ask,ask_unidentified,ask_stale,answer_choice_out_of_range, andanswer_not_suggested. Start addsinvalid_agent_name,unsupported_agent_kind,agent_detection_unavailable,agent_name_conflict,target_not_shell,invalid_launch_argv,ambiguous_integration(exit 2 — more than one enabled integration’s[agent_identity]claims the requested--kind; the message names every claimant and--integration IDchooses),agent_start_timeout, andagent_kind_mismatch. Watch addsunknown_event_name.remedyis always present and non-empty: the next command to run, in prose.exit_codemirrors the process’s own exit status, so a consumer that only captured stderr still learns it. Exit-code semantics are unchanged from §5.2 (0ok,1miss / no server,2refusal / usage,3partial view where the verb can spend it,124/125timeouts) — under--jsona partial-view miss carriespartial_viewinerror.codeeven for the shared-resolver verbs whose status must stay1.- Warnings (e.g. partial-fleet notices on a successful resolution) still precede the error line on stderr as prose; the JSON error object is the final line.
Without --json the same failures stay prose (message plus an indented
remedy block), so nothing changes for humans or for scripts that grep
stderr.
Why there is no -j short flag. Considered and rejected in ADR-0065 §7:
--json is typed almost exclusively by scripts and agents, where
explicitness is worth more than two saved characters, and the binary keeps
its short-flag surface reserved for high-frequency human-typed options.
TUI defaults and scope
The reference TUI's consumer-facing product surface: subcommands, keybinds, status bar, layout, hooks, recording.
Agent CLI
The structured CLI surface an AI agent drives without a TTY: create with new, place configured agents or explicit argv with launch / spawn, reshape exact...