titah run only ever streamed text to stdout. That is enough to read and not
nearly enough for anything else: a pipeline, CI, a script, a tool that wants to
use Titah as a part of itself. Everything a caller needs — did the turn succeed,
which tools ran, how many tokens, did it stop because it finished or because it
ran out of budget — existed only as coloured text in among the answer.
Three formats
titah run --output-format text "…" # default, unchanged
titah run --output-format json "…" # one object at the end
titah run --output-format stream-json "…" # one event per line, as it happens
stream-json is deliberately not a new format: it is Titah’s own Event
union, one per line, followed by a final {"type":"result",…}. A second shape
would be two things to keep in step, and the second always falls behind the
moment an event is added.
The result object
{
"session": "ses_…",
"ok": true,
"agent": "build",
"model": "9router/ant",
"text": "…", // the answer only — reasoning never leaks in
"tools": [
{ "tool": "bash", "title": "npm test", "status": "completed" },
{ "tool": "task", "title": "qc", "status": "completed", "outcome": "failed" }
],
"usage": { "input": 41233, "output": 918, "context": 42151 },
"notices": ["Stopped at limits.turnTokens (400000). Raise it or split the work."]
}
status and outcome answer different questions, which is why they are
separate fields. status is “did the tool run”; outcome is “was the
result any use”. A task whose sub-agent had every tool denied still completes,
and a script reading status alone would call that a success.
notices is where “stopped at the token budget” reaches the caller. Without it,
the only difference between a finished turn and a truncated one is the length of
the text, and no script can judge that. See Cost & limits.
!!! warning “In json modes, nothing human touches stdout”
Not tidiness. One session: ses_… line in front of the object breaks
JSON.parse, and the caller has no way to guess that the line is at fault
rather than the data. Progress and notices go to stderr.
Requiring a shape
titah run --json-schema shape.json "list the failing tests"
{
"type": "object",
"required": ["failures"],
"properties": {
"failures": {
"type": "array",
"items": {
"type": "object",
"properties": { "file": { "type": "string" }, "reason": { "type": "string" } }
}
}
}
}
The shape is asked for in the prompt and checked locally, rather than using provider-native structured output. Most OpenAI-compatible endpoints do not support that, and a feature that works on some providers is a feature that exists in the docs and not on your machine.
The checker is a subset of JSON Schema — type, required, properties,
items, enum. Unknown keywords are skipped, not failed: a schema written
against the full spec still works for the parts Titah understands, instead of
being rejected wholesale for a minLength nobody needed here.
On a match the parsed value appears as output on the result object, beside
text rather than replacing it — a script reads output, and a human debugging
why it is wrong reads the text that produced it.
Exit codes
| Code | Meaning |
|---|---|
0 | The turn finished |
1 | The turn failed |
2 | The turn succeeded, but the answer was not the requested shape |
1 and 2 are separate because the fix is different: retry versus change the
prompt. Collapsing them makes a script retry forever against a prompt that will
never produce the right shape.
In a pipeline
set -e
result=$(titah run --output-format json --auto --json-schema shape.json "…")
echo "$result" | jq -r '.output.failures[].file'
Remember that with no client connected, any tool needing a dialog is
auto-denied. --auto, or an explicit permission.allowlist, is the automation
path — see Permissions & undo.