Skip to content
titah

Automation

Structured output

`titah run` in three shapes — text for people, one JSON object for scripts, one event per line for anything following along — plus a schema check and exit codes that mean different things.

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

CodeMeaning
0The turn finished
1The turn failed
2The 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.