phux
guidesagents and automation

Agent JSON contracts

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...

evolving document
Full source summary

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 existing panes with insert-pane / move-pane / swap-pane, act through run, send-keys, or paste, observe through bounded wait / watch, and raise advisory human attention with ask. Plugin, workspace, and satellite verbs provide the surrounding configuration and inventory surfaces. This file is the agent contract. Per ADR-0030, the structured agent state — cells, command results, semantic events — is a local projection over the shared engine, and the CLI plus its versioned JSON schemas are what an agent depends on, not a structured wire tier. It documents each verb, its JSON shape, the read-act-wait loop, and the exit codes each verb mirrors.

4. JSON contracts (the per-verb machine shapes)

Each --json verb emits a versioned, plain-data struct from phux-core or phux-client. These structs are the stable agent contract (ADR-0022); they are a local projection over the shared engine, and the wire underneath stays additive and versioned. Each struct carries its own schema_version, tracked independently.

4.1 SessionListJsonphux ls --json

Defined in crates/phux-core/src/session_list.rs (LS_SCHEMA_VERSION = 3). Version 2 added the aggregate terminals inventory; version 3 adds unreachable. attached_clients arrived later without a bump — adding a key is non-breaking under this contract (consumers ignore unknown keys), so schema_version moves only when a key is removed, renamed, or retyped. Shape, name-sorted:

{
  "schema_version": 3,
  "sessions": [
    { "name": "work", "windows": 3, "attached": true, "attached_clients": 2 }
  ],
  "terminals": ["@3", "devbox/@7"],
  "unreachable": []
}

windows is the window count; attached_clients is the number of currently attached clients; attached is the same fact as a bool (attached_clients > 0), kept for consumers that branch on it. A payload from a pre-attached_clients phux simply lacks the key. terminals is the complete addressable inventory in snapshot order, using the canonical direct selector syntax. Satellite entries intentionally do not imply a hub-local session/window join.

unreachable is how you know the listing is complete. A federation hub that cannot reach a satellite still answers — it merges what it has and never fails the list (§ tui.md federation) — so without this field a partial inventory is byte-identical to a whole one. Each entry is one hub diagnostic naming a satellite that contributed nothing ("satellite build-box is unreachable: link is down"). The presence of entries is the contract; the prose is a diagnostic, so branch on unreachable == [], never on substrings. The key is emitted even when empty, deliberately: an absent key is what a pre-v3 phux produces, and a consumer cannot tell that apart from a degraded answer. Treat sessions and terminals as a lower bound whenever it is non-empty.

The MCP phux_ls tool (mcp.md §3.1) executes and parses this same canonical CLI document, so one parser covers both surfaces.

4.2 ScreenStatephux snapshot --json (and phux wait --json)

Defined in crates/phux-core/src/screen.rs (SCHEMA_VERSION = 3). The same struct the server returns from GET_SCREEN, not an agents-specific shape. Fields:

FieldTypeMeaning
schema_versionu32Contract version (currently 3); the pin/branch signal.
paneu32Wire-local id of the captured pane.
cols, rowsu16Grid dimensions.
cursorOption<{x,y,visible}>Viewport-relative, zero-based; None when the cursor is not viewport-resident (scrollback or hidden).
linesVec<String>Viewport rows, top to bottom, right-trimmed.
scrollbackVec<String>History rows above the viewport, oldest first; empty unless requested.
cellsOption<Vec<CellInfo>>Per-cell marks and styles; present only with --cells.
soft_wrapOption<{lines: [u32], scrollback: [u32]}>Indices of returned rows that continue onto the row below, from libghostty’s per-row wrap bit.
truncatedboolTrue when the requested window dropped older rows. Absent means false.
truncated_reasonOption<String>Why. The only value this server produces is "row_window".
titleOption<String>The pane’s OSC 0/2 title, when it set one.

soft_wrap is a three-way answer, not a two-way one. Present and non-empty is “these rows wrap”; present and empty is “wrapping was reported, and nothing wraps”; absent is “the producer says nothing about wrapping,” which today identifies a server predating the field. A consumer that unwraps by default has to tell the last two apart, and no version number can express the difference — the older server would not have moved one either. Indices are per-array, and a wrapped final scrollback index continues into lines[0]: history and viewport are one stream for wrapping purposes, so joining them per-array is wrong. phux wait already unwraps (§2); a consumer doing its own substring matching should too, because a match against raw lines silently fails when the text straddles a wrap.

truncated is scoped to the requested window--scrollback N against more retained history than N, or a --tail window narrower than the rendered stream. It says nothing about rows the emulator itself evicted from its history ring long ago, which is unknowable. truncated_reason is a string rather than an enum precisely so a future reason is not a hard deserialize failure; tolerate a value you do not recognize.

title is the ADR-0046 detector’s highest-ranked evidence and no other read surface exposed it, so an offline agent explain --file capture lost it. None means the pane set no title or the producer predates the field; both are “no title to reason about,” which is the same fail-safe answer.

schema_version did not move for any of these four, and that is the contract working, not an oversight. §4.1’s rule governs the whole family: a version moves when a key is removed, renamed, or retyped, never when one is added, because consumers ignore keys they do not know. All four are #[serde(default)] with skip_serializing_if, so an untruncated, title-less snapshot with no wrap data serializes byte-identically to the pre-ADR-0077 shape. A consumer probing for wrap support therefore tests for the presence of soft_wrap, not for a version — the version is what the server can do, not what this payload contains.

scrollback is tri-state (mirrors mcp.md §3.2): flag absent → viewport only; --scrollback or --scrollback=0 → all retained history; --scrollback N → the most-recent N rows. On the wire this is None / Some(0) (all) / Some(n).

--cells populates cells with a sparse Vec<CellInfo> — only cells carrying a non-default style or an OSC-133 mark, in row-major order, skipping the right half of double-width glyphs. Each CellInfo is { col, row, semantic?, style }:

  • semantic is SemanticContentInput (typed input) or Prompt (shell prompt). Output is the default for every cell and is collapsed to absence, so semantic is Some only for marked input vs prompt.
  • style is CellStyle: nine SGR booleans (bold, faint, italic, underline, blink, inverse, invisible, strikethrough, overline) plus fg / bg, each a CellColor tagged enum with kind of default, palette ({ index }), or rgb ({ r, g, b }). The tag distinguishes “terminal default” from “explicitly black”.

Back-compat. scrollback and cells are #[serde(default)] (and cells is skip_serializing_if None), so a cells = None snapshot serializes to exactly the pre-cells shape, and an older consumer reading a newer payload ignores extra keys. schema_version is the signal for a breaking change — a removal, rename, or retype — not for an added key; probe for the key itself when you need to know whether a producer supplies one.

4.3 RunResultphux run --json (on completion)

Defined in crates/phux-client/src/run.rs:

{
  "command": "cargo test",
  "exit_code": 0,
  "output": "...",
  "duration_ms": 8123,
  "truncated": false
}
  • exit_code (i32) is the child’s $?, parsed out of a printed sentinel (run brackets the command with BEGIN/RC markers — it does not rely on shell integration).
  • output is the rows between the BEGIN and RC markers.
  • duration_ms (u64) is wall-clock from submit to sentinel-seen, including poll latency — an upper bound on the child’s runtime, not a precise measurement.
  • truncated is true when the BEGIN marker had scrolled out of the viewport, so output is best-effort visible context; a full capture needs scrollback.

On timeout, run --json emits no JSON. RunOutcome::TimedOut carries the command, elapsed time, and last screen internally, but the CLI’s --json path serializes only the completed RunResult. The timeout signal is the exit code (125 — see §5.2), printed alongside a stderr diagnostic. An agent must read the exit code here and must not expect an outcome: "timed_out" body — that shape exists in the MCP phux_run tool (mcp.md §3.4), not in the CLI’s --json output.

4.4 phux new --json

phux new --json -s NAME emits a small fixed object naming the created session and its seed pane’s wire-local id, then exits 0 without attaching:

{ "schema_version": 1, "session": "NAME", "terminal_id": 2 }

It is create-only: --json requires an explicit -s NAME (a parse-time rule; omitting -s is a usage error, exit 2) and errors (exit 1) if that name is already in use. Repeat --env KEY=VALUE to inject environment entries into the seed process; values may contain additional = characters. The wire decomposition behind it is in §2.

4.5 Plugin registry — phux plugin ... --json

The plugin lifecycle surface is config-local. It edits or reads [[plugins]] entries and validates referenced phux-plugin.toml manifests; it does not load plugin code into phux and does not run plugin commands.

phux plugin list --json and phux plugin validate --json emit:

{
  "schema_version": 1,
  "plugins": [
    {
      "id": "example.agent-tools",
      "name": "Agent Tools",
      "version": "0.1.0",
      "min_phux_version": "0.0.2",
      "description": null,
      "manifest": "./plugins/agent-tools/phux-plugin.toml",
      "manifest_path": "/abs/path/phux-plugin.toml",
      "plugin_root": "/abs/path",
      "enabled": true,
      "platforms": null,
      "build": [],
      "actions": [],
      "events": [],
      "panes": [],
      "links": []
    }
  ]
}

validate --json also carries "valid": true. link, enable, and disable wrap the same plugin object under "plugin"; unlink wraps the removed object under "removed". The registry JSON enumerates declarative actions, event hooks, pane providers, and link handlers from each manifest but does not execute them. Invalid or missing manifests are hard failures: exit nonzero, stdout empty, stderr diagnostic.

4.6 ConfiguredAgentsJsonphux config agents --json

phux config agents --json emits configured plugin agent declarations as a consumer-ready list, merged with live runtime state when a server answers (phux-r82.10). Schema history: version 1 was the pure manifest projection; version 2 (current) makes state/attention the effective values — runtime phux.agent/v1 record first, declared manifest baseline as fallback — and adds live, source, declared, and runtime:

{
  "schema_version": 2,
  "live": true,
  "agents": [
    {
      "plugin_id": "example.agent-tools",
      "plugin_enabled": true,
      "id": "codex",
      "label": "Codex",
      "description": "Coding agent",
      "state": "blocked",
      "attention": "high",
      "source": "runtime",
      "declared": { "state": "working", "attention": "normal" },
      "runtime": {
        "terminal": "@3",
        "name": "codex",
        "kind": "codex",
        "state": "blocked",
        "attention": "high",
        "asked": false
      },
      "contexts": ["workspace", "pane"]
    }
  ]
}

state is one of unknown, idle, working, blocked, or (runtime only) done. attention is one of none, low, normal, or high. live is whether a server answered; with live: false every row is source: "manifest". source is "runtime" when a live phux.agent/v1 record matched the row (record kind slug, else lowercased name, equals the agent id — the same identity derivation as phux agent), "manifest" otherwise; runtime is null for manifest rows. When several panes declare the same agent, the most attention-worthy binding is reported. Attention follows the record’s convention: declared value first, else derived from state (blocked→high, working→normal, done/unknown→low, idle→none). An active ADR-0035 ask on the matched pane sets runtime.asked and elevates a record that declares no state to blocked; a declared record state outranks the ask sentinel (ADR-0040). Invalid manifests are hard failures and leave stdout empty on --json, preserving the script contract.

4.7 AgentStateJsonphux agent ... --json

phux agent list --json, phux agent show --json [TARGET], and phux agent explain --json [TARGET] emit the same versioned shape. explain differs only in the human output; JSON always includes the evidence trail:

{
  "schema_version": 1,
  "agents": [
    {
      "terminal": "@3",
      "session": "work",
      "window": "window-0",
      "agent": { "id": "codex", "label": "Codex", "kind": "codex" },
      "state": "blocked",
      "confidence": 0.95,
      "attention": "high",
      "title": "phux-ask[deploy]:Approve deploy??s=Yes|No",
      "cwd": "/repo",
      "sources": [
        {
          "kind": "title_ask",
          "signal": "phux-ask title sentinel",
          "confidence": 0.95,
          "observed": "phux-ask[deploy]:Approve deploy??s=Yes|No"
        }
      ],
      "explanation": "waiting on a reported human-answerable ask"
    }
  ]
}

agent.kind is codex, claude, opencode, pi, omp, plugin, declared, or unknown. state is unknown, idle, working, blocked, or done; attention is none, low, normal, or high. sources is sorted by descending confidence and is the provenance contract: current sources include agent_record, title_ask, screen, semantic_cells, identity, and plugin_report. A structured phux.agent/v1 record outranks heuristics; without one, a plugin report remains lower precedence than a live phux-ask title sentinel or an explicit blocked/completed screen cue. Unknown/missing signals stay unknown or low-confidence idle.

This is a public clean-room projection. It does not copy external agent manifests or private tradecraft rules; built-in recognition comes from publicly observable process identity and captured pane chrome, plus optional local phux plugin declarations.

AgentWaitJsonphux agent wait --json

{
  "schema_version": 1,
  "terminal": "@7",
  "satisfied": true,
  "edge": { "from": "working", "to": "idle", "via": "push" },
  "baseline": "working",
  "state": "idle",
  "agent": { "name": "reviewer", "kind": "claude", "session": null },
  "observations": { "edges": 1, "pushes": 2, "polls": 3 },
  "detection": null
}

edge is null exactly when satisfied is false — the timeout case, exit 124, where the document still goes to stdout, matching phux wait --json. Only the typed failures of §5.3 leave stdout empty. via is "push" (a server change notification) or "poll" (the re-read floor recovering an edge the push half never delivered); it is diagnostic, not contract-critical.

baseline is the pre-wait level: recorded and never evaluated. It is in the document so a caller that timed out can see it was already resting in the state it asked for — the one outcome the edge rule makes surprising.

detection is one entry of the agents array above — the same object, with confidence and the full sources evidence trail for this pane — so a caller can tell a state an integration declared (agent_record provenance) from one a screen rule derived. On a pane phux itself instrumented that trail is always the derivation: the shipped hook shim declares identity only, so no state on such a pane comes from a hook. It is null, as shown, when the post-wait read fails; that degrades the document by one optional field and never fails a wait that was already satisfied. Without --json the prose line is @N<TAB>name<TAB>from -> to<TAB>via push|poll<TAB>confidence.

phux agent send-keys --json

{
  "schema_version": 1,
  "terminal": "@7",
  "agent": { "name": "reviewer", "kind": "claude" },
  "keys": 2,
  "verified": true,
  "delivery": "ok",
  "operation_id": "...",
  "attempts": 1
}

Emitted only on a fully delivered batch; without --json the verb says nothing on success, exactly as phux send-keys does. keys is the number of key specs delivered, and verified records that the occupant check ran and passed. Refusals follow §5.3.

phux agent prompt --json

The result records both halves of the fused operation. On wait timeout it still goes to stdout and the process exits 124; delivery: "ok" means the prompt reached the kernel tty queue even though no target transition was observed.

{
  "schema_version": 1,
  "terminal": "@7",
  "delivery": "ok",
  "operation_id": "...",
  "agent": { "name": "reviewer", "kind": "claude", "state": "working", "session": null },
  "pre_submit_state": "idle",
  "staleness_bound_ms": null,
  "attempts": 1,
  "submit_ms": 8,
  "transition_observed": true,
  "matched_by": "transition",
  "edge": { "from": "working", "to": "idle", "via": "push" },
  "waited_ms": 1200,
  "degraded_to_polling": false
}

phux agent answer --json

{
  "schema_version": 1,
  "terminal": "@7",
  "ask": { "id": "deploy", "question": "Deploy?", "suggestions": ["Yes", "No"] },
  "answer": "Yes",
  "source": "choice",
  "operation_id": "...",
  "delivered": true
}

phux agent start --json

The ready result includes terminal, name, kind, integration, started, ready, state, shell_check, and a readiness object containing identity, transition, detector provenance, latency, and observation counts. With --no-wait, ready is false and readiness is null. A readiness timeout is a §5.3 error document on stderr and exits 124; the launch command was already delivered.

AgentExplainJsonphux agent explain --file ... --json

The offline explainer emits a different document from AgentStateJson: it reports what the detection manifests did to one captured screen, not what phux believes about a live pane. The top-level key is explain, so the two are distinguishable without reading further.

{
  "schema_version": 1,
  "capture": {
    "path": "screen.json",
    "format": "json",
    "rows": 42,
    "cols": 120,
    "title": "phux"
  },
  "explain": {
    "kind": "claude",
    "name": "claude",
    "state": "blocked",
    "detector_state": "blocked",
    "matched_rule": "prompt-permission-dialog",
    "freeze": false,
    "visible_idle": false,
    "regions": [
      { "region": "title", "empty": false, "lines": ["phux"] },
      { "region": "prompt-box", "empty": true, "lines": [] }
    ],
    "evaluated_rules": [
      {
        "id": "prompt-permission-dialog",
        "priority": 80,
        "region": "after-last-rule",
        "state": "blocked",
        "matched": true,
        "visible_idle": false,
        "skip_state_update": false,
        "evidence": {
          "op": "all",
          "matched": true,
          "children": [
            { "op": "contains", "pattern": "do you want to ", "matched": true },
            { "op": "line-regex", "pattern": "^\\s*\\d+\\.\\s+\\S", "matched": true }
          ]
        }
      }
    ]
  }
}

capture.format is json or text — what the bytes were actually parsed as, after --format auto sniffed them. capture.cols is null for a text capture, which declares no grid width. capture.title is the --title value, or the empty string when none was supplied.

state is the state a rule asserted and is absent when none did. detector_state is what the detector would publish: the asserted state, else idle when nothing matched, else frozen when a skip-state-update rule matched and the previous state is held. When the two differ, fallback_reason says which case applied. detector_state is the field to branch on.

regions covers every region the manifest grammar offers — title, prompt-box, after-last-rule, bottom-lines, viewport, in that order — whether or not a rule names it, and empty is true when the region resolved to nothing a predicate can see. Region previews are never elided in JSON; the prose form caps each region and reports how many rows it dropped. evaluated_rules lists every rule in declaration order including the misses; evidence is the predicate tree with a per-node matched, and every child of a combinator is evaluated, so a failing all shows which conjunct failed rather than only that one did. region and evidence[].op use the manifest’s own spellings, so a value read here can be typed straight back into a TOML rule.

Failures follow §5.3, with the codes named there.

4.8 PluginActionOutputphux config run --json

Defined in crates/phux-plugin/src/lib.rs (schema_version = 1). Shape:

{
  "schema_version": 1,
  "plugin_id": "example.agent-tools",
  "action_id": "summarize",
  "command": ["python3", "summarize.py"],
  "cwd": "/path/to/plugin",
  "outcome": "completed",
  "exit_code": 0,
  "stdout": "...",
  "stderr": "",
  "duration_ms": 42
}

outcome is "completed" or "timed_out". exit_code is null when the OS does not provide a process code or when phux kills the child on timeout. The runtime executes the manifest’s argv directly from the plugin root, captures stdout/stderr lossily as UTF-8, inherits the phux process environment, and adds PHUX_PLUGIN_ID, PHUX_PLUGIN_ACTION_ID, and PHUX_PLUGIN_ROOT.

4.9 Workspace commands — phux workspace ...

phux workspace inspect --json is repo-local. It shells out to git’s porcelain worktree listing and reports the current worktree plus siblings as a stable JSON projection:

{
  "schema_version": 1,
  "repo": {
    "path": "/abs/path/repo",
    "head": "012345...",
    "branch": "main",
    "detached": false
  },
  "worktrees": [
    {
      "path": "/abs/path/repo-feature",
      "head": "89abcd...",
      "branch": "feature",
      "detached": false,
      "current": false
    }
  ]
}

For detached worktrees, branch is null and detached is true. Missing or non-git paths are hard failures: exit nonzero, stdout empty, stderr diagnostic. The command is intentionally read-only; creation and deletion stay in git/plugin/provider territory rather than the terminal substrate.

phux workspace save emits a separate archive shape:

{
  "schema_version": 2,
  "sessions": [
    {
      "name": "agent-bench-codex",
      "active": true,
      "windows": [
        {
          "name": "0",
          "active": true,
          "panes": [
            {
              "active": true,
              "title": "codex",
              "cwd": "/repo",
              "command": null,
              "agent_session": {
                "plugin_id": "com.phux.agent-tools",
                "integration_id": "codex",
                "native_id": "019c2f31-77d2-7a93-8931-47d27b46ceda"
              },
              "cols": 120,
              "rows": 40
            }
          ]
        }
      ]
    }
  ]
}

command is nullable because process argv is not always known. Plugin-authored archives may fill it, and workspace restore uses it when present; otherwise it starts the default shell in the saved cwd when available. agent_session is also nullable. When present, it is inert provenance, not executable input: restore re-resolves the current integration, requires the same plugin_id, and builds structured resume argv from the current template. It never replays archived argv as resume authority. Existing session names are skipped, and restore prints a schema-2 summary JSON document with restored and skipped_existing arrays. Schema-1 archives remain readable and retain their fresh-process behavior.

Restored sessions are fresh PTYs. The archive preserves window/pane metadata and split-layout shape for inspection and future replay, but the current restore command only recreates missing sessions and their seed process. Use phux upgrade for live PTY handoff across a server re-exec; do not present workspace restore as resurrecting already-running processes.

4.10 Host registry — phux host ... --json

The machine-registry surface is config-local (host enroll excepted — it drives ssh). add, ls, and rm edit or read [[remote]] and [[satellites]] entries and do not dial remote hosts.

phux host ls --json emits one merged document:

{
  "schema_version": 1,
  "hosts": [
    {
      "name": "devbox",
      "role": "satellite",
      "endpoint": "ssh://devbox",
      "enabled": true,
      "token_file": null,
      "cert_fingerprint": null,
      "session": null
    }
  ]
}

enabled is null for role: "remote" entries (the schema has no enabled bit) and session is null for satellites (a hub-dialed link has no arrival to attach). --role filters the array to one registry. add --json and enroll --json wrap one such object under "host"; rm --json emits {"schema_version": 1, "removed": {"name": ..., "role": ...}}. Invalid names, invalid endpoint URIs, duplicate configured names, and refused registry writes are hard failures: exit nonzero, stdout empty, one contract line (§5.3) on stderr.

4.11 phux spawn --json

phux spawn --json emits a small fixed object naming the spawned terminal:

{
  "schema_version": 1,
  "terminal_id": 7,
  "satellite": null
}

satellite is the registry name when the spawn was routed with --satellite NAME (in which case terminal_id is the id on that satellite — address the pane through the hub by the pair), and null for a local spawn (address it as @7). Failures — no route to the named satellite, unreachable satellite link, server-side spawn failure — exit nonzero with stdout empty and the typed diagnostic on stderr.

4.12 Spatial layout edits

Each successful --json spatial edit emits a schema_version: 1 document. Common fields are operation and session_id; insert adds target_terminal_id, new_terminal_id, direction, and ratio; move adds source_terminal_id, target_terminal_id, direction, and ratio. A cross-session move also adds source_session_id and cross_session: true, while session_id names the destination. Swap adds first_terminal_id and second_terminal_id. direction retains the CLI’s user-facing divider meaning (vertical = side-by-side, horizontal = stacked), not the layout tree’s internal child-axis enum.

{
  "schema_version": 1,
  "operation": "insert-pane",
  "session_id": 3,
  "target_terminal_id": 7,
  "new_terminal_id": 9,
  "direction": "vertical",
  "ratio": 0.3
}

With --json, failures emit the shared JSON error contract (§5.3) on stderr and leave stdout empty. Stable codes for spatial edits include invalid_selector, selector_miss, selector_not_single, satellite_target, cross_session, same_pane, invalid_ratio, layout_missing, pane_not_in_layout, pane_already_in_layout, and layout_rejected. Cross-session moves may also report server_too_old, move_refused, post_move_state_failed, destination_changed, destination_layout_failed, or source_layout_failed; these are exit 1 because ownership or transport work has begun, while preflight selector and layout refusals remain exit 2.

4.13 phux launch --json

A successful launch returns the resolved integration identity, the final argv actually spawned (including a generated fresh identity or explicit resume identity when its template declares one), and the new local terminal id:

{
  "schema_version": 1,
  "terminal_id": 11,
  "integration": "codex",
  "plugin": "com.phux.agent-tools",
  "argv": ["phux-agent-wrap.sh", "codex"]
}

phux launch --list --json instead returns { "schema_version": 1, "integrations": [...] }; --print --json returns the resolved cwd, working_directory, and the same prepared argv without spawning. Placement does not add a second result shape: --target, --split, and --ratio affect the persisted topology while the launch JSON remains the object above.

4.14 phux rec --json

One object on stdout on success, and nothing else on stdout ever. Progress and every diagnostic go to stderr, and progress is suppressed entirely under --json:

{
  "schema_version": 1,
  "path": "demo.gif",
  "format": "gif",
  "bytes": 188742,
  "duration_ms": 42130,
  "frames": 211,
  "cols": 120,
  "rows": 34,
  "truncated": false
}

format is one of cast, gif, apng. duration_ms is the recording’s own timeline after the idle clamp, not wall time spent recording. frames is the count of encoded animation frames — for format: "cast" there are no frames, so it reports the cast’s event count instead. cols/rows are the recorded grid. truncated is true when encoding stopped at --max-bytes: the file is a complete, playable container, just shorter than the capture.

Unlike §4.1–§4.3’s engine-state projections, this object has no producing struct in phux-core — it is a plain result line — but it carries the same schema_version contract as every other --json verb in this catalog.

Exit codes: 0 on success, including a capture you ended with Ctrl-C; 1 on failure (no server, unresolvable target, unknown output extension, unreadable --from file, write or encode failure). A failed export is still exit 1, but the captured .cast is deliberately retained and its path printed, so the recovery is phux rec --from <that path> -o <target>.

4.15 phux resize --json

A schema_version: 1 object naming what was asked for and what the server actually holds afterwards:

{
  "schema_version": 1,
  "terminal_id": 7,
  "requested": { "cols": 120, "rows": 40 },
  "applied": { "cols": 120, "rows": 40 },
  "held": true
}

applied is read back from the server, not echoed from the request, so it is the geometry a following phux snapshot will report. held is applied == requested on both axes and mirrors the exit code, so a script can branch on either. Without --json the same fact prints as one line, 120x40 — the applied size, so phux resize demo 120x40 is safe to read with $(...).

Divergence from the other --json verbs: the object is printed on the failure path too, and stdout is not left empty. Elsewhere a nonzero exit means the command did not run; here it means the command ran and the server holds a different size, and that size is exactly what the caller needs in order to react. Transport failures — no server, unresolvable target — do leave stdout empty, as everywhere else.

4.16 phux play --json

One object on stdout naming the pane that was created and the recording it is playing:

{
  "schema_version": 1,
  "terminal_id": 7,
  "path": "/home/me/demo.cast",
  "cols": 80,
  "rows": 24,
  "events": 63,
  "speed": 1.0,
  "idle_limit": 2.0,
  "duration_ms": 17198,
  "passes": 1
}

terminal_id is the payload: everything you do next — snapshot @7, resize @7, rec @7, kill @7 — is addressed by it. path is absolute, because the pane’s process resolves it from the daemon’s working directory and not yours. cols/rows are the recording’s grid, which is also the size the pane is fitted to unless --no-fit was given or something else owns the pane’s size. duration_ms is how long the playback will take at the requested speed, after the idle clamp — the wait you are actually in for, not the recording’s raw length. idle_limit is the clamp that was applied (null when none was). passes is the number of times the recording will play, and null means it repeats until the pane is killed.

Like §4.14, this object has no producing struct in phux-core — it is a result line, not a projection of engine state — but it carries the same schema_version contract as every other --json verb in this catalog.

Exit codes: 0 once the pane exists; 1 on failure (no server, unreadable or malformed cast, unresolvable TARGET, a refused spawn). A failure creates no pane: the cast is parsed in the caller’s own process, before anything is spawned.

4.17 phux tag --json

All three tag actions (ls, add, rm — and their list / remove aliases) emit one schema_version: 1 document with a row per resolved Terminal:

{
  "schema_version": 1,
  "terminals": [
    { "terminal": "@7", "tags": ["build", "ci"] },
    { "terminal": "edge/@3", "tags": [] }
  ]
}

terminal is the canonical, reusable selector for that Terminal — @N locally, host/@N for a satellite pane through a hub — so each row’s id can be fed straight back into any TARGET-taking verb. tags is the Terminal’s complete tag list: for ls as stored, and for add / rm as read back from the server after the write (the confirming GET_METADATA round-trip), never echoed from the request. An untagged Terminal is an empty list, not an absent key.

Failures follow §5.3: a dead socket emits the contract with no_server, an unparseable TARGET invalid_selector, and a selector miss splits no_such_target (exit 1) from partial_view (exit 3) exactly as the prose path splits the exit codes (§5.2). Partial-fleet warnings on a successful resolution stay prose on stderr ahead of the document.

4.18 phux pair --json

phux pair --json never contacts a running server (see remote-access.md §“Pairing”); it mints or reads a bearer token and reports it alongside everything a device needs to dial this host:

{
  "schema_version": 1,
  "credential_id": "0123456789abcdef0123456789abcdef",
  "generation": 1,
  "token": "deadbeef...64 hex chars",
  "cert_fingerprint": "AB:CD:...64 hex chars",
  "overlay_addresses": ["100.64.0.2"],
  "ws_addr": "0.0.0.0:8787",
  "quic_addr": null,
  "connect_link": "https://phux.phall.io/connect?url=wss://100.64.0.2:8787&token=deadbeef...",
  "tokens_path": "/home/me/.local/state/phux/remote-tokens"
}

ws_addr and quic_addr are the server’s configured bind (from the environment its listener reads), not a resolved dialable address — pair them with an overlay_addresses entry to build one, which is exactly what connect_link already did for you. Each is null when this host has no listener of that kind configured; phux host enroll reads that as the signal to fall back to ssh://. overlay_addresses is best-effort (ADR-0037) and empty, never absent, when nothing was detected. connect_link is null whenever no address source (neither --host nor a detected overlay address plus a known port) exists to build one from — a device then has to be given the address by another channel. The token printed in this document is a secret and is only ever emitted once; it is not re-derivable from the token store afterwards. credential_id is the non-secret stable ID used with phux pair rotate and phux pair revoke; generation starts at one and increments on rotation.

phux pair rotate CREDENTIAL_ID --json emits a separate schema-version 1 operation document with operation: "rotate", credential_id, the new generation, one-time token, overlap_seconds, and tokens_path. phux pair revoke CREDENTIAL_ID --json emits operation: "revoke", credential_id, and tokens_path; it never emits any bearer token. Rotation keeps prior generations valid only for the requested overlap and never beyond their existing absolute expiry. An already-expired credential is rejected before a replacement token is generated, so failed rotation emits no JSON document or secret. Revocation and rotation affect new connections; an established session retains its admission until it disconnects.

View exact source