Output contracts
- The stdout contract
- Declaring an output contract
- Truncated answers
- Text output
- JSON output
- JSON Schema validation
- Exit codes
The stdout contract
stdout carries the command result and nothing else. Diagnostics, warnings and runtime information go to stderr, always.
gko summarize README.md > summary.txt # the file contains the summary, nothing more
cat ticket.md | gko classify | jq . # safe to pipe into a JSON toolThis holds on failure paths too: when a command fails, stdout is empty — zero bytes — and the error is on stderr. It holds for argument errors raised by the CLI parser itself, and for the built-ins, whose report is their result and therefore goes to stdout.
It also holds whatever --verbose says. Verbosity moves a threshold on the diagnostic stream
only: --verbose info adds engine traces to stderr and changes stdout by not one byte. See
Verbosity.
Declaring an output contract
[output]
format = "json"
schema = "schemas/classification.json"| Key | Type | Default | Notes |
|---|---|---|---|
format |
"text" | "json"
|
"text" |
|
schema |
name or path | none | JSON only; see Schema paths |
max_lines |
integer | none | text only |
allow_truncated |
boolean | false |
accept an answer cut short by max_tokens; see Truncated answers
|
strip_reasoning |
boolean | false |
remove a leading <think>...</think> block; see Reasoning models
|
extract |
JSON pointer | none | JSON only; stdout gets one value; see Extracting one value |
Rejected at load time, naming the command file:
-
schematogether withformat = "text"— a schema means nothing for free text; -
max_linestogether withformat = "json"— likewise; -
extracttogether withformat = "text", or anextractnot starting with/; - any unknown key under
[output].
format = "json" without a schema is allowed: the response is then only checked for being
well-formed JSON.
Schema paths
A schema is declared in one of three forms:
| Form | Example | Resolves to |
|---|---|---|
| bare name | schema = "classification" |
<scope root>/schemas/classification.json |
| relative path | schema = "schemas/classification.json" |
<scope root>/schemas/classification.json |
| absolute path | schema = "/srv/schemas/ticket.json" |
itself |
A value without a / and without a .json suffix is a name; anything else is a path. A relative
path resolves against the scope root of the command file, not against the current directory
and not against the command file itself — schemas/ is a sibling of commands/. A command coming
from /etc/gekko therefore looks in /etc/gekko/schemas/. The depth of the command path makes no
difference: commands/git/review.md still resolves against the scope root.
Sending the schema to the model
When the command's backend declares structured_output = true (see
Configuration), the output schema is sent with the
request as an OpenAI response_format of type json_schema: a server that supports it constrains
the model's answer to the schema, so the prompt does not need to describe the expected shape. The
answer is validated against the schema afterwards either way.
Schemas in the prompt
A command can also paste schemas into its prompt. Declare them in a [schemas] table, one id per
schema, in any of the three forms above, and reference them with {{ schemas.<id> }}:
[schemas]
ticket = "ticket"
[output]
format = "json"
schema = "ticket"Answer with a JSON object matching this schema:
{{ schemas.ticket }}A placeholder naming an id missing from [schemas] is rejected at load time, naming the command
file. The placeholder renders the schema document as compact JSON.
Schemas are loaded only when the command actually runs — before its input is read and
before the backend is contacted. A schema that is
missing or malformed on a command nobody invokes does not break the rest of the CLI. Checking all
of them is what gko doctor is for.
Text output
The response is trimmed of leading and trailing whitespace. Nothing is parsed, nothing is unwrapped.
[output]
format = "text"
max_lines = 1If max_lines is declared and the response has more non-empty lines than that, the command fails
with exit code 4. The output is never silently truncated — a response that does not meet
the declared contract is a failure, not something to repair.
Truncated answers
A backend that stops generating because it hit max_tokens (its own default, or the model's
declared [generation].max_tokens) reports it as finish_reason = "length". gko treats that as
an execution failure by default — exit code 4 — exactly like a schema violation or an
max_lines overrun: a cut-off answer did not honor the command's contract any less than a
malformed one.
[output]
allow_truncated = trueSetting allow_truncated = true accepts the truncated answer as-is instead: it is finalized and
written to stdout like any other answer, and the command exits 0.
Truncation never triggers the fallback retry: the fallback
exists for a prompt an NPU-served model refuses as too long, not for an answer that ran out of
max_tokens — the model answered, it just did not finish.
The behavior differs slightly with the terminal versus a pipe:
-
piped or redirected (
gko ... > file,gko ... | jq .): stdout stays completely empty on a truncated answer that is not accepted — the answer never reaches it, byte one included. -
a terminal, streaming: tokens already reached the screen as they arrived, before
gkocould know the stream would end truncated. The exit code is still4; only the closing frame is skipped. A calling program reads the exit code and stderr, so it sees the failure either way.
Reasoning models
Some backends inline the model's reasoning into content itself, wrapped in <think>...</think>.
Left as-is, that breaks
format = "json" (the reasoning is not valid JSON, or sits before the JSON body) and
max_lines = 1 (the reasoning adds lines the contract did not expect).
[output]
format = "json"
schema = "classification"
strip_reasoning = truestrip_reasoning = true removes exactly one leading <think>...</think> block —
whitespace-tolerant before the opening tag, but the closing tag is required: an unclosed block is
treated as content, never guessed at. This runs before the rest of the pipeline (fence
removal, JSON parsing, schema validation, or the text branch's trim/max_lines). A block found
anywhere other than the very start — in particular, in the middle of the answer — is left
untouched.
The removed text is logged at info by length only
(stripped 412 characters of reasoning), never by content: reasoning can be long, and the
diagnostic stream is not a transcript.
Streaming is disabled when strip_reasoning = true, even on a terminal that would otherwise
stream a plain-text answer: printing tokens as they arrive would show the reasoning block before
gko has a chance to strip it. The answer then arrives in one piece, as it does for a JSON output
contract. gko describe reports the key, so
the behavior is discoverable without reading the command file.
A server that reports reasoning in a separate reasoning_content field rather than inlining it
into content needs no handling here: only content is read. strip_reasoning is for the inline
<think> case specifically, and the tag is not configurable.
JSON output
[output]
format = "json"
schema = "schemas/ticket.json"The pipeline is:
model response → strip Markdown fences → parse JSON → validate against schema → stdout
Fenced responses
Models very often wrap their JSON in a Markdown code fence. gko removes an opening fence at the
start and its closing fence at the end, with or without a language tag, tolerating surrounding
whitespace:
```json
{"category": "bug", "confidence": 0.91}
```
A fence appearing in the middle of the response is left alone — that is content, not wrapping.
Normalised output
What reaches stdout is the compact serialisation of the parsed value, so stdout is always valid JSON whatever the model wrapped around it:
cat ticket.md | gko classify | jq .categoryExtracting one value
A shell pipeline usually wants one value, not a document. extract names it with a JSON pointer:
[output]
format = "json"
schema = "classification"
extract = "/category"category=$(cat ticket.md | gko classify) # hardware, not {"category":"hardware",...}The schema is still validated on the whole document; only then is the pointed value written:
a string bare, without quotes, anything else as compact JSON. A pointer the document does not
resolve is an output failure (exit 4), naming the pointer.
extract shapes the CLI's stdout only. An MCP client still receives the whole document, which
is what the tool's advertised output schema describes, and the expectations of
gko config test address the whole document too.
JSON Schema validation
Schemas are ordinary JSON Schema documents:
{
"type": "object",
"required": ["category", "confidence"],
"properties": {
"category": { "type": "string" },
"confidence": { "type": "number", "minimum": 0, "maximum": 1 }
},
"additionalProperties": false
}Validation failures list every violation, not just the first one, so a prompt can be fixed in one pass instead of one error at a time.
A response that is not valid JSON, or that violates the schema, is an execution failure with exit
code 4. gko does not retry, does not reformulate, and does not ask the model again: a program
driving the CLI gets a stable contract rather than a best effort.
Exit codes
| Code | Kind | Meaning |
|---|---|---|
0 |
— | success |
1 |
I/O | unreadable file, broken pipe |
2 |
Configuration | your files are wrong — the message names the file |
3 |
Backend | unreachable, or a non-2xx HTTP response |
4 |
Output | the model's answer violated the declared contract |
The distinction between 2 and 4 is the useful one for a calling program: 2 means your
configuration is broken, 4 means your configuration is fine and the model answered badly.
A missing or malformed schema file is 2, because the fault is in the configuration, even though
it is only discovered when the command runs.