phux
guidesagents and automation

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.

evolving document
Agent path

Start with the operating loop. Open exact contracts only when your integration needs them.

Drive a terminalThe bounded loop an agent should use in practice.Find a commandCLI verbs grouped by task and side effect.Consume JSONVersioned machine shapes and compatibility rules.

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 --json shapes are what an agent depends on.
  • mcp.md is a thin adapter that wraps the same phux-client functions name-for-name over JSON-RPC stdio.
  • sdk.md documents phux-client itself — 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:

VerbExit codes
ls0 ok; 1 no server / unexpected result.
snapshot0 ok; 1 failure (no server, serialize error, resolve miss).
send-keys0 ok; 1 failure (no server / refused / miss).
paste0 ok (including a paste the pane’s untrusted policy silently dropped); 1 failure (no server / refused / miss / unreadable stdin).
ask0 accepted; 1 no server, unknown pane, or invalid ask payload.
agent0 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).
runthe 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.
wait0 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 wait0 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_targetphux.agent/v1 does not federate); 1 the agent departed mid-wait (record deleted or state withdrawn to unknown), no server, or transport.
agent send-keys0 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 prompt0 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 answer0 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 start0 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.
new0 ok; 1 duplicate -s name / failure.
resize0 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).
rename0 renamed; 1 no server or transport failure; 2 unknown source session or destination name already exists.
launch / spawn0 spawned/resolved/listed; 1 invalid integration, placement, server, or spawn failure.
watch0 Ctrl-C, plain EOF, or an --until match; 124 --timeout; 2 unknown event name; 1 transport or EOF before a requested event.
rec0 ok, including a capture ended by Ctrl-C; 1 no server, unresolvable target, unknown output extension, unreadable --from file, or write/encode failure.
plugin0 ok; 1 invalid/missing manifest, invalid config, refused registry write, or unknown plugin id.
workspace0 ok; 1 missing git repo, invalid git output, no server for save/restore, invalid archive, or JSON render failure.
satellite0 ok; 1 invalid name/endpoint, duplicate configured name, invalid config, refused registry write, or unknown satellite name.
insert-pane / move-pane / swap-pane0 ok; 1 transport failure; 2 selector, ratio, session, or layout refusal.
kill0 ok; 1 selector miss / no server / parse; 2 server-side refusal; 3 the miss is not trustworthy — see below.
tag0 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_version is 1; new fields are additive and do not bump it.
  • error.code is a closed vocabulary owned in one place by commands/json_err.rs; branch on it, never on message text. 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) and partial_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 check could not run at all — unreadable file or malformed TOML; exit 2, mirroring its prose path’s distinct “could not check” status), and json_serialize (a result document failed to render — a phux bug worth filing). The update family (phux update): update_invalid_tag and update_unsupported_platform (exit 2 — a tag that is not vX.Y.Z, or a platform with no published artifact); update_source_unsupported, update_immutable_store, and update_package_managed (exit 2 — phux will not write to this install, and remedy carries the exact native command); update_fetch_failed, update_checksum_invalid, update_checksum_mismatch, update_archive_rejected, update_install_failed, and update_no_backup (exit 1). A update_checksum_mismatch means 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) and capture_invalid (the bytes are not a screen — JSON that is not a ScreenState, or a capture with no rows), both exit 1; and unknown_agent_kind (exit 2 — --kind was omitted or names no loaded detection manifest, with the roster in remedy). The agent-lifecycle family (agent wait, agent send-keys, agent prompt, agent answer, agent start): no_agent_record (exit 2 — the pane declares no phux.agent/v1 record, 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/v1 is 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 not no_agent_record: the remote pane may well have a live agent), agent_departed (exit 1 — the record was deleted or its state withdrew to unknown mid-wait; a departure, never a completion), agent_mismatch (exit 2 — the pane hosts a different agent than --expect-agent / --expect-kind named, and nothing was written), and invalid_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 adds input_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_written is the case the server can rule out delivery for, delivery_unknown is the case it cannot), input_too_large, input_lease_held, canonical_limit_exceeded, unsafe_paste, invalid_input_batch, and permission_denied. Ask validation adds no_active_ask, ask_unidentified, ask_stale, answer_choice_out_of_range, and answer_not_suggested. Start adds invalid_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 ID chooses), agent_start_timeout, and agent_kind_mismatch. Watch adds unknown_event_name.
  • remedy is always present and non-empty: the next command to run, in prose.
  • exit_code mirrors the process’s own exit status, so a consumer that only captured stderr still learns it. Exit-code semantics are unchanged from §5.2 (0 ok, 1 miss / no server, 2 refusal / usage, 3 partial view where the verb can spend it, 124/125 timeouts) — under --json a partial-view miss carries partial_view in error.code even for the shared-resolver verbs whose status must stay 1.
  • 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.

View exact source