Scripting the CLI
Every oxagen command is scriptable — pretty in a terminal, NDJSON when piped. The dual-mode contract, exit codes, the session event envelope, and a jq cookbook.
oxagen is built to be piped. Every command detects whether stdout is a terminal: in a terminal you get human-readable output, and piped into another process you get JSON a machine can parse. You rarely have to pick a format. The CLI reads the pipe.
Dual-mode output
Two rules cover the whole CLI:
- In a terminal, commands print for a human: tables, colored lines, progress.
- Piped, or with
--json, commands print machine output: a single JSON value for a result, or one JSON object per line (NDJSON) for a stream.
--json forces machine mode anywhere. --quiet drops the progress chrome but never the data. The streaming fleet commands (fleet watch, fleet logs, fleet dispatch --follow, fleet attach) switch to NDJSON automatically the moment stdout is not a terminal, so oxagen fleet watch | jq just works with no extra flag.
stdout and stderr discipline
The split is strict, and it is what makes piping safe:
- stdout carries only the answer — the result value in
--jsonmode, or the NDJSON event lines of a stream. Nothing else is ever written to stdout. - stderr carries everything else — progress, hints, warnings, and errors.
So oxagen … --json | jq never chokes on a stray progress line, because progress was never on stdout in the first place. Redirect stderr away with 2>/dev/null when you want the data and nothing else.
Exit codes
| Code | Meaning |
|---|---|
0 | Success. |
1 | Runtime error. The command ran but failed. |
2 | Usage or validation error: bad flags, an unknown session. |
Two streaming commands additionally exit with the session's fate, so a script can branch on whether the agent actually succeeded:
oxagen fleet dispatch --follow …oxagen fleet attach <sid> --json
Both exit 0 when the session ends done, and 1 when it ends failed or cancelled:
if oxagen fleet dispatch --once --follow "run the test suite" >/dev/null; then
echo "green"
else
echo "the agent could not get it green"
fiErrors in JSON mode
In machine mode an error is a single line on stderr, and the exit code is non-zero:
{"type":"error","code":"not_found","message":"no session matches 'zzzz'"}The event envelope
Fleet sessions speak one wire format: the session event envelope. It is the same stream everywhere. Mission Control renders it, --json serializes it, and events.ndjson on disk is it. NDJSON, one JSON object per line, no trailing whitespace.
The envelope is a public contract. It evolves additively within v: 1: new event types and new optional fields are safe, and a breaking change bumps v. Pin v if you parse it.
Every line
Every event, whatever its type, carries these four fields plus type:
| Field | Type | Meaning |
|---|---|---|
v | 1 | Envelope version. Bumped only on a breaking change. |
sid | string | The session id the event belongs to. |
seq | integer | Strictly monotonic per session, starting at 1. Resume a reader with --from-seq. |
ts | integer | Event time, epoch milliseconds. |
type | string | One of the 14 event types below. |
Event types
Assistant text arrives twice: live as message.delta chunks, then whole in message.end, so a consumer that only wants finished answers can select message.end and skip reassembly. User messages are atomic (message). Readers must tolerate a torn final line from a crash mid-append; the CLI's own readers simply skip any line that does not parse.
type | Fields (beyond the five above) | Emitted when |
|---|---|---|
session.start | title, prompt, cwd, model?, agent?, mode (conversation|once), owner (tui|worker), pid | A session begins. |
session.state | state (queued|running|waiting|done|failed|cancelled), reason? | The session changes state. waiting means idle between turns, inbox open. |
stage | kind (evaluate|plan|enhance|route|execute|judge|revise|complete), label, detail? | The engine enters a pipeline stage. |
message | role (user), text, turn | A user prompt opens a turn. |
message.delta | text, turn | A chunk of the assistant's answer streams in. |
message.end | text, turn | The assistant's answer for a turn is complete (full text). |
reasoning.delta | text, turn | A chunk of the model's reasoning streams in. |
tool.start | name, input | A tool call begins. input is JSON, capped at 2 KB. |
tool.end | name, ok, durationMs | A tool call finishes. ok is false on failure. |
diff | changedFiles (string array), changedLines | Files changed in an execution round (cumulative for the round). |
turn.end | turn, steps, stopReason?, changedFiles (string array), usage | A turn settles. usage is that turn's tokens and cost. |
usage | cumulative | Session-cumulative tokens and cost, after each turn. |
error | message, fatal | Something went wrong. fatal ends the session. |
session.end | state (done|failed|cancelled), summary, durationMs, usage | The session ends. usage is the session total. |
Every usage object (on turn.end, on usage.cumulative, and on session.end) has the same shape:
{ "inputTokens": 18422, "outputTokens": 3110, "costUsd": 0.184 }jq cookbook
Practical recipes against the envelope and the ls snapshot. Each is runnable as-is.
Total cost across a fleet
fleet ls --json is a JSON array of session snapshots, each with a cumulative usage. Sum the cost:
oxagen fleet ls --json | jq '[.[].usage.costUsd] | add'A cost and token table
oxagen fleet ls --json \
| jq -r '.[] | [.sid, .derivedState, .usage.costUsd, .usage.outputTokens] | @tsv' \
| column -tEvery file edited across the fleet
Walk each session's log and collect the changed files:
for sid in $(oxagen fleet ls --json | jq -r '.[].sid'); do
oxagen fleet logs "$sid"
done | jq -r 'select(.type=="diff") | .changedFiles[]' | sort -uCount failed tool calls, by tool
for sid in $(oxagen fleet ls --json | jq -r '.[].sid'); do
oxagen fleet logs "$sid"
done | jq -s '
[ .[] | select(.type=="tool.end" and .ok==false) ]
| group_by(.name)
| map({ tool: .[0].name, failures: length })
| sort_by(-.failures)'Stream one agent's assistant text
Live, as it types (-j joins the deltas with no newline):
oxagen fleet attach s-k3x --json | jq -j 'select(.type=="message.delta") | .text'Or one clean block per finished turn:
oxagen fleet logs s-k3x --follow | jq -r 'select(.type=="message.end") | .text'Wait for a session to finish
The exit code of dispatch --follow already is the wait:
oxagen fleet dispatch --once --follow "apply the migration" >/dev/null
echo "fate: $?" # 0 done, 1 failed or cancelledTo wait on a session that is already running, block until its session.end:
oxagen fleet logs s-k3x --follow \
| jq --unbuffered 'select(.type=="session.end") | .state' \
| head -n 1head -n 1 closes the pipe on the first match, which ends the follow.
Dispatch a batch of tasks from a file
One prompt per line in tasks.txt. dispatch returns immediately, so the whole file is launched in seconds:
xargs -I{} oxagen fleet dispatch --once "{}" < tasks.txtThen watch them land:
oxagen fleet watch --json \
| jq -c 'select(.type=="session.end") | {sid, state, cost: .usage.costUsd}'Related
- Fleet — Mission Control, the
oxagen fleetcommands, and the cross-terminal model. - Command reference — every command and its
--jsonbehavior.
Fleet
Run many agents at once as one view. A session is an append-only event log on disk; Mission Control, JSON pipes, and a second terminal are all renderers of the same stream.
Configuration
Layered settings.json, model selection, permissions and hooks, and project-level agents, slash commands, and rules.