OxagenDocs
CLI

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 --json mode, 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

CodeMeaning
0Success.
1Runtime error. The command ran but failed.
2Usage 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"
fi

Errors 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:

FieldTypeMeaning
v1Envelope version. Bumped only on a breaking change.
sidstringThe session id the event belongs to.
seqintegerStrictly monotonic per session, starting at 1. Resume a reader with --from-seq.
tsintegerEvent time, epoch milliseconds.
typestringOne 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.

typeFields (beyond the five above)Emitted when
session.starttitle, prompt, cwd, model?, agent?, mode (conversation|once), owner (tui|worker), pidA session begins.
session.statestate (queued|running|waiting|done|failed|cancelled), reason?The session changes state. waiting means idle between turns, inbox open.
stagekind (evaluate|plan|enhance|route|execute|judge|revise|complete), label, detail?The engine enters a pipeline stage.
messagerole (user), text, turnA user prompt opens a turn.
message.deltatext, turnA chunk of the assistant's answer streams in.
message.endtext, turnThe assistant's answer for a turn is complete (full text).
reasoning.deltatext, turnA chunk of the model's reasoning streams in.
tool.startname, inputA tool call begins. input is JSON, capped at 2 KB.
tool.endname, ok, durationMsA tool call finishes. ok is false on failure.
diffchangedFiles (string array), changedLinesFiles changed in an execution round (cumulative for the round).
turn.endturn, steps, stopReason?, changedFiles (string array), usageA turn settles. usage is that turn's tokens and cost.
usagecumulativeSession-cumulative tokens and cost, after each turn.
errormessage, fatalSomething went wrong. fatal ends the session.
session.endstate (done|failed|cancelled), summary, durationMs, usageThe 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 -t

Every 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 -u

Count 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 cancelled

To 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 1

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

Then watch them land:

oxagen fleet watch --json \
  | jq -c 'select(.type=="session.end") | {sid, state, cost: .usage.costUsd}'
  • Fleet — Mission Control, the oxagen fleet commands, and the cross-terminal model.
  • Command reference — every command and its --json behavior.

On this page