Structured Output
Structured output constrains the LLM to produce responses that conform to a user-provided JSON Schema. Instead of free-form text, the model must return valid JSON matching the schema exactly. This matters for scripting, CI pipelines, and any use case where the output needs machine-readable structure rather than prose.
The challenge is threefold: (1) not all models or API endpoints support schema enforcement natively, (2) even with API support the schema must be threaded through every layer of the architecture, and (3) fallback behavior when the model ignores the constraint must be handled gracefully.
This page covers schema-constrained final outputs, not general tool dispatch. For tool registry/dispatch flow, see Tool Use. For schema instructions embedded in prompt construction, see System Prompt. For size pressure from large JSON payloads, see Token Budgeting.
Aider Implementation
Section titled “Aider Implementation”Aider does not support structured output — and this is an intentional design choice.
Aider’s entire editing pipeline is built around the Search/Replace block format, a structured text format that proved more reliable than JSON for code editing. Their benchmarking found that requiring JSON output degrades code quality and increases token usage.
The relevant evidence:
aider/coders/editblock_coder.pyimplements Search/Replace blocks — a human-readable diff format that avoids JSON entirely.- Aider’s model settings are defined in
aider/resources/model-settings.yml; there are nostructured_output,json_schema, orresponse_formatfields there. - The
aider/models.pymodel configuration coversedit_format,lazy,reminder,examples_as_sys_msg, etc. — but nothing related to JSON schema enforcement.
Why: Aider’s position is that forcing JSON on code-editing tasks wastes tokens on syntax overhead (quotes, braces, escaping) that add no semantic value. The Search/Replace format is far more token-efficient and less error-prone for diff-based editing.
Reference:
aider@b9050e1
Codex Implementation
Section titled “Codex Implementation”Codex implements structured output natively through the OpenAI Responses API’s text.format parameter. The schema is provided per-turn and flows untouched from the CLI through the protocol into the API request.
Entry Point: CLI Flag
Section titled “Entry Point: CLI Flag”The --output-schema FILE flag accepts a path to a JSON Schema file:
// codex-rs/exec/src/cli.rs:78-80/// Path to a JSON Schema file describing the model's final response shape.#[arg(long = "output-schema", value_name = "FILE")]pub output_schema: Option<PathBuf>,The schema file is loaded at startup by load_output_schema():
fn load_output_schema(path: Option<PathBuf>) -> Option<Value> { let path = path?; let schema_str = match std::fs::read_to_string(&path) { Ok(contents) => contents, Err(err) => { eprintln!("Failed to read output schema file {}: {err}", path.display()); std::process::exit(1); } }; match serde_json::from_str::<Value>(&schema_str) { Ok(value) => Some(value), Err(err) => { eprintln!("Output schema file {} is not valid JSON: {err}", path.display()); std::process::exit(1); } }}Note: Codex exits immediately on invalid JSON. There is no fallback — the schema must parse cleanly.
Protocol Layer
Section titled “Protocol Layer”The loaded schema is passed as final_output_json_schema through the Op::UserTurn and Op::UserInput variants in the protocol:
// codex-rs/protocol/src/protocol.rs:104-149pub enum Op { UserInput { items: Vec<UserInput>, #[serde(skip_serializing_if = "Option::is_none")] final_output_json_schema: Option<Value>, }, UserTurn { items: Vec<UserInput>, cwd: PathBuf, // ...other fields... final_output_json_schema: Option<Value>, // ... }, // ...}The schema lives on the Prompt struct in codex-core:
// codex-rs/core/src/client_common.rs:26-45pub struct Prompt { pub input: Vec<ResponseItem>, pub(crate) tools: Vec<ToolSpec>, pub(crate) parallel_tool_calls: bool, pub base_instructions: BaseInstructions, pub personality: Option<Personality>, /// Optional the output schema for the model's response. pub output_schema: Option<Value>,}API Request Construction
Section titled “API Request Construction”The create_text_param_for_request() function in codex-api converts the schema into the text.format field for the Responses API request:
pub fn create_text_param_for_request( verbosity: Option<VerbosityConfig>, output_schema: &Option<Value>,) -> Option<TextControls> { if verbosity.is_none() && output_schema.is_none() { return None; } Some(TextControls { verbosity: verbosity.map(std::convert::Into::into), format: output_schema.as_ref().map(|schema| TextFormat { r#type: TextFormatType::JsonSchema, strict: true, schema: schema.clone(), name: "codex_output_schema".to_string(), }), })}Key details:
strict: true— always enforced. Codex never uses loose schema matching.name: "codex_output_schema"— hardcoded identifier for the output schema in the API payload.type: "json_schema"— the Responses API discriminant.
The final request payload sent to the API includes:
{ "model": "gpt-5.2-codex", "text": { "format": { "name": "codex_output_schema", "type": "json_schema", "strict": true, "schema": { /* user-provided schema */ } } }}Wire Call
Section titled “Wire Call”In client.rs, the build_responses_request() method calls create_text_param_for_request() and places the result in the text field:
// codex-rs/core/src/client.rs:460-520fn build_responses_request( &self, provider: &codex_api::Provider, prompt: &Prompt, model_info: &ModelInfo, effort: Option<ReasoningEffortConfig>, summary: ReasoningSummaryConfig,) -> Result<ResponsesApiRequest> { // ... let text = create_text_param_for_request(verbosity, &prompt.output_schema); let request = ResponsesApiRequest { model: model_info.slug.clone(), // ... text, }; Ok(request)}Per-Turn Semantics
Section titled “Per-Turn Semantics”The output schema is per-turn, not per-session. Codex’s app-server test send_user_turn_output_schema_is_per_turn_v1 explicitly verifies this: the first turn sends a schema (and the API request includes text.format), then the second turn sends output_schema: None and the API request omits text.format entirely.
Test Coverage
Section titled “Test Coverage”- Unit test:
serializes_text_schema_with_strict_formatincodex-rs/core/src/client_common.rsvalidates the serialized JSON shape. - Integration test (exec):
exec_includes_output_schema_in_requestincodex-rs/exec/tests/suite/output_schema.rsvalidates the full CLI → API wire path. - Integration test (app-server):
send_user_turn_accepts_output_schema_v1andsend_user_turn_output_schema_is_per_turn_v1incodex-rs/app-server/tests/suite/output_schema.rsvalidate schema flow through the MCP server protocol.
Reference:
codex@4ab44e2
OpenCode Implementation
Section titled “OpenCode Implementation”OpenCode implements structured output through a tool-injection pattern — it dynamically creates a StructuredOutput tool that the model must call to produce its response, rather than using the API’s native response_format parameter.
Schema Definition
Section titled “Schema Definition”The user’s input message can carry a format property specifying a JSON schema:
export const OutputFormatJsonSchema = z .object({ type: z.literal("json_schema"), schema: z.record(z.string(), z.any()).meta({ ref: "JSONSchema" }), retryCount: z.number().int().min(0).default(2), }) .meta({ ref: "OutputFormatJsonSchema" })The retryCount field (default: 2) is part of the message schema, but in the current session loop implementation it is not consumed to drive automatic retries. The schema is a generic JSON Schema record.
System Prompt Injection
Section titled “System Prompt Injection”When the session loop detects format.type === "json_schema", it appends a hard instruction to the system prompt:
// packages/opencode/src/session/prompt.ts:60const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested structured output. You MUST use the ` + `StructuredOutput tool to provide your final response. Do NOT respond ` + `with plain text - you MUST call the StructuredOutput tool with your ` + `answer formatted according to the schema.`This prompt is appended to the system messages array:
// packages/opencode/src/session/prompt.ts:654-657const system = [...(await SystemPrompt.environment(model)), ...(await InstructionPrompt.system())]if (format.type === "json_schema") { system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)}Tool Injection
Section titled “Tool Injection”The createStructuredOutputTool() factory builds a Vercel AI SDK tool whose inputSchema is the user’s JSON schema:
// packages/opencode/src/session/prompt.ts:922-949export function createStructuredOutputTool(input: { schema: Record<string, any> onSuccess: (output: unknown) => void}): AITool { const { $schema, ...toolSchema } = input.schema
return tool({ id: "StructuredOutput" as any, description: STRUCTURED_OUTPUT_DESCRIPTION, inputSchema: jsonSchema(toolSchema as any), async execute(args) { // AI SDK validates args against inputSchema before calling execute() input.onSuccess(args) return { output: "Structured output captured successfully.", title: "Structured Output", metadata: { valid: true }, } }, toModelOutput(result) { return { type: "text", value: result.output, } }, })}Key details:
- The
$schemaproperty is stripped from the schema before creating the tool (it’s a metaproperty, not part of the data shape). - The AI SDK’s
jsonSchema()wrapper provides validation —execute()is only called if the model’s arguments match the schema. - The
onSuccesscallback captures the validated output into a closure variable.
The tool description instructs the model clearly:
const STRUCTURED_OUTPUT_DESCRIPTION = `Use this tool to return your final response in the requested ` + `structured format.\n\nIMPORTANT:\n` + `- You MUST call this tool exactly once at the end of your response\n` + `- The input must be valid JSON matching the required schema\n` + `- Complete all necessary research and tool calls BEFORE calling this tool\n` + `- This tool provides your final answer - no further actions are taken after calling it`Tool Choice Enforcement
Section titled “Tool Choice Enforcement”When structured output is requested, toolChoice is set to "required":
// packages/opencode/src/session/prompt.ts:678toolChoice: format.type === "json_schema" ? "required" : undefined,This forces the model to call some tool — combined with the system prompt instruction, this ensures it calls StructuredOutput.
Injection Point
Section titled “Injection Point”The tool is injected into the tools map at the start of each session loop step:
// packages/opencode/src/session/prompt.ts:612-620if (lastUser.format?.type === "json_schema") { tools["StructuredOutput"] = createStructuredOutputTool({ schema: lastUser.format.schema, onSuccess(output) { structuredOutput = output }, })}Success and Error Handling
Section titled “Success and Error Handling”On success: When the model calls StructuredOutput, the loop captures the result and stores it:
// packages/opencode/src/session/prompt.ts:683-688if (structuredOutput !== undefined) { processor.message.structured = structuredOutput processor.message.finish = processor.message.finish ?? "stop" await Session.updateMessage(processor.message) break}The structured output is persisted to the message in the SQLite database via message.structured.
On failure: If the model finishes without calling the tool:
// packages/opencode/src/session/prompt.ts:693-702if (format.type === "json_schema") { processor.message.error = new MessageV2.StructuredOutputError({ message: "Model did not produce structured output", retries: 0, }).toObject() await Session.updateMessage(processor.message) break}The message is marked with a StructuredOutputError, which the UI can potentially use to retry.
Reference:
opencode@7ed4499
Pitfalls & Hard Lessons
Section titled “Pitfalls & Hard Lessons”JSON vs. Code Quality Trade-off (Aider)
Section titled “JSON vs. Code Quality Trade-off (Aider)”Aider’s benchmarking found that JSON output degrades coding performance. Token overhead from JSON syntax (escaping, quoting, structural braces) competes with the model’s context capacity for actual code content. Any structured output implementation for coding tasks should offer JSON as an opt-in mode, not a default.
Per-Turn vs. Per-Session Schema (Codex)
Section titled “Per-Turn vs. Per-Session Schema (Codex)”Codex explicitly made output schemas per-turn. This is important because different turns in a conversation may need different output shapes (or no schema at all). A per-session schema would force every response — including tool call results and intermediate reasoning — through the same rigid shape. Codex’s test send_user_turn_output_schema_is_per_turn_v1 validates that turn 2 without a schema produces no text.format field.
Tool Injection vs. Native API Support (OpenCode)
Section titled “Tool Injection vs. Native API Support (OpenCode)”OpenCode chose tool injection over response_format because their AI SDK abstraction layer needs to work across multiple model providers, some of which may not support native structured output. The tool-based approach works with any model that supports function calling — at the cost of an extra round-trip if the model doesn’t comply. The retryCount field exists in the schema, but the current loop exits after the first failure and does not automatically consume that value for retries.
Strict Mode is Non-Negotiable (Codex)
Section titled “Strict Mode is Non-Negotiable (Codex)”Codex hardcodes strict: true — there is no option for loose schema matching. This means schemas must include required and additionalProperties: false to pass OpenAI’s strict validation. Users who provide schemas without these fields will get API errors.
Error Granularity (OpenCode)
Section titled “Error Granularity (OpenCode)”OpenCode’s error handling is binary: either the model called the tool or it didn’t. There’s no partial credit for “almost valid JSON” or “called the tool but with wrong arguments.” The AI SDK handles argument validation internally, so if execute() runs, the output is guaranteed schema-conformant.
No Schema Validation at CLI (Codex)
Section titled “No Schema Validation at CLI (Codex)”Codex validates that the file contains valid JSON but does not validate that it’s a valid JSON Schema. An invalid schema (e.g., {"type": "dinosaur"}) would be passed to the API, which would reject it at request time — not at CLI parse time. This is a gap in the user experience.
OpenOxide Blueprint
Section titled “OpenOxide Blueprint”OpenOxide should support both approaches — native API schema enforcement for providers that support it, and tool-based injection as a fallback.
Proposed Architecture
Section titled “Proposed Architecture”┌──────────────┐ ┌──────────────────┐ ┌─────────────────────┐│ CLI Layer │ │ Core Engine │ │ Provider Adapter ││ │ │ │ │ ││ --output- │────▶│ OutputSchema │────▶│ format_output() ││ schema │ │ on Turn struct │ │ ││ <FILE> │ │ │ │ ┌───────────────┐ ││ │ │ load_schema() │ │ │ OpenAI: │ ││ (also via │ │ validate_schema()│ │ │ text.format │ ││ programmatic│ │ │ │ ├───────────────┤ ││ API) │ │ │ │ │ Anthropic: │ ││ │ │ │ │ │ tool injection│ │└──────────────┘ └──────────────────┘ │ ├───────────────┤ │ │ │ Local/OSS: │ │ │ │ tool injection│ │ └──┴───────────────┘ │ └─────────────────────┘Key Traits and Types
Section titled “Key Traits and Types”/// A validated JSON Schema attached to a turn.pub struct OutputSchema { /// The raw schema as a serde_json::Value. pub schema: serde_json::Value, /// Whether strict mode is enforced (always true for OpenAI). pub strict: bool,}
/// Provider-specific structured output strategy.pub trait StructuredOutputAdapter { /// Returns true if this provider supports native JSON schema enforcement. fn supports_native_schema(&self) -> bool;
/// Apply the schema to the request using the provider's native mechanism. fn apply_native_schema( &self, request: &mut ApiRequest, schema: &OutputSchema, );
/// Generate a tool definition that enforces the schema via tool calling. fn generate_schema_tool( &self, schema: &OutputSchema, ) -> ToolDefinition;}Crates
Section titled “Crates”| Crate | Responsibility |
|---|---|
openoxide-schema | JSON Schema loading, validation (jsonschema crate), OutputSchema type |
openoxide-core | Turn struct carries Option<OutputSchema>, calls adapter |
openoxide-provider | StructuredOutputAdapter trait + per-provider impls |
openoxide-cli | --output-schema <FILE> flag, delegates to openoxide-schema |
Validation Improvement Over Codex
Section titled “Validation Improvement Over Codex”Add schema validation at load time using the jsonschema crate:
use jsonschema::JSONSchema;
pub fn load_and_validate_schema(path: &Path) -> Result<OutputSchema> { let raw = std::fs::read_to_string(path)?; let value: serde_json::Value = serde_json::from_str(&raw)?;
// Validate that it's a valid JSON Schema JSONSchema::compile(&value) .map_err(|e| anyhow::anyhow!("Invalid JSON Schema: {e}"))?;
Ok(OutputSchema { schema: value, strict: true, })}This catches invalid schemas before they hit the API, providing a better error message than a 400 from the provider.
Tool Injection Fallback
Section titled “Tool Injection Fallback”For providers without native schema support (Anthropic, local models), implement OpenCode’s tool-injection pattern in Rust:
pub fn create_structured_output_tool(schema: &OutputSchema) -> ToolDefinition { ToolDefinition { name: "StructuredOutput".to_string(), description: STRUCTURED_OUTPUT_DESCRIPTION.to_string(), parameters: schema.schema.clone(), strict: true, }}
pub const STRUCTURED_OUTPUT_SYSTEM_PROMPT: &str = "IMPORTANT: The user has requested structured output. You MUST use the \ StructuredOutput tool to provide your final response. Do NOT respond \ with plain text.";Per-Turn Semantics
Section titled “Per-Turn Semantics”Follow Codex’s design: the schema is per-turn on the Turn struct, not per-session. This allows mixed conversations where some turns require structured output and others don’t.