Structured output

From docs/structured.md at 947399f View on GitHub

runa can force a model's answer into a JSON Schema or a GBNF grammar. Local models use llama.cpp grammar sampling, so the output always matches. OpenAI models get the schema as response_format (strict: true).

CLI

runa run qwen "Capital of France and its population?" \
  --json-schema '{"type":"object","properties":{"city":{"type":"string"},"population":{"type":"integer"}},"required":["city","population"]}'
runa run qwen "Is Paris in France?" --grammar yes-no.gbnf
FlagValueNotes
--json-schemafile path, or inline JSON starting with {Local, OpenAI, and Anthropic (forced tool, see below).
--grammarGBNF fileLocal only. Conflicts with --json-schema.

A constrained request turns thinking off, and it turns off the Zig kernel sampler and n-gram speculation. The grammar must see every token.

runa serve

POST /v1/chat/completions accepts the OpenAI response_format:

typeEffect
textNo constraint
json_objectAny JSON object ({"type":"object"})
json_schemajson_schema.schema is required; the answer matches it

An invalid schema returns 400.

Tool calling

Both POST /v1/chat/completions and POST /v1/messages accept tools and return calls in the API's own shape:

OpenAIAnthropic
Toolstools (type: function)tools (name, input_schema)
Choiceauto, required, none, {"function":{"name":…}}auto, any, none, {"type":"tool","name":…}
Calls outmessage.tool_calls, finish_reason: tool_callstool_use blocks, stop_reason: tool_use
Results inrole: tool + tool_call_idtool_result blocks

Streaming sends each call once, when generation ends: one tool_calls delta for OpenAI, and content_block_start / input_json_delta / content_block_stop per call for Anthropic.

The model's chat template decides the call format (Hermes <tool_call> for Qwen3, Harmony <|channel|>commentary to=functions.… for gpt-oss — covered by harmony_tool_reply_parses on the real 20B template — generic JSON when the template has no tool support). auto uses a lazy grammar that starts at the format's trigger, so the model can still answer in plain text or think first. required and a named tool use an eager grammar. A model without a chat template rejects tools with 400. Tools cannot be combined with image or audio input.

MCP tool loop (run / chat)

runa run qwen3 "What is the weather in Paris?" --mcp 'python3 weather_server.py'
runa chat qwen3 --mcp 'npx -y @modelcontextprotocol/server-filesystem .'

--mcp '<command args>' (repeatable) and [mcp.servers.<name>] in config (docs/config.md) start stdio MCP servers. Their tools go to the model. When the model calls one, runa runs it on the server that listed it, sends the result back as a tool message, and generates again. This repeats until the model answers without a call, or --max-tool-rounds (default 8) is reached, which is an error. Each call is logged to stderr as [tool] name(args) -> N bytes. A failed call goes back to the model as error: … text.

BackendHow tools travel
LocalChat template, as in runa serve above
OpenAItools, assistant tool_calls, role: tool messages
Anthropictools, tool_use blocks (sent back whole, thinking included), tool_result blocks

--mcp splits with shell quoting (P10.8): '...' is literal, "..." allows backslash escapes, and a backslash outside quotes escapes the next character — --mcp "python3 'my dir/s.py'" works. Unterminated quotes are an error. Anything fancier (env vars, per-server env) goes in [mcp.servers]. Two servers that offer the same tool name are an error. Chat keeps history across turns (P10.9): every turn sends the transcript so far, tool rounds included. /reset and /model clear it.

Anthropic structured output (--json-schema) uses the same mechanism: one forced answer tool whose input_schema is the schema. Its input is the answer. Forcing a tool turns thinking off, and it cannot be combined with --mcp.

How it renders

Constrained requests render through llama.cpp's Jinja chat handler (apply_chat_template_oaicompat). The handler knows the model's chat format. It returns the prompt, the grammar, lazy-grammar triggers, and extra stop strings. Models without a chat template fall back to the plain prompt plus an eager grammar. Schemas are converted with llama.cpp's json_schema_to_grammar (runa_engine::schema_to_grammar).