Structured Output
This guide covers how to get structured (schema-validated) output from LLM providers in Nexus. The system uses a three-layer design: schema declaration → request tagging → provider execution, with the existing json_schema gate as a safety net.
How It Works
- A schema is registered with the Schema Registry (via
schema.registerevent or direct API) - An LLM request is tagged with
_expects_schemametadata pointing to the schema name - The Schema Registry attaches a
ResponseFormatto the request - The provider maps
ResponseFormatto its native structured output mechanism (or simulates it) - The
json_schemagate optionally validates the response as a safety net
Scenarios
1. Skill with Inline Output Schema
The simplest path — declare the schema directly in your SKILL.md frontmatter.
SKILL.md:
---
name: extract-entities
description: Extract named entities from text.
output_schema:
type: object
required: [entities]
properties:
entities:
type: array
items:
type: object
required: [name, type]
properties:
name: { type: string }
type: { type: string, enum: [person, org, location, date] }
---
# Entity Extraction
Extract all named entities from the user's text. Return only the JSON output.
Config:
plugins:
active:
- nexus.skills
- nexus.llm.openai # or nexus.llm.anthropic
- nexus.agent.react
No additional config needed — the skills plugin handles registration automatically.
What happens:
- User triggers skill activation → skills plugin loads
output_schemafrom frontmatter - Skills plugin emits
schema.registerwith nameskill.extract-entities.output - On each LLM request while skill is active, skills plugin tags
_expects_schema = "skill.extract-entities.output"viabefore:llm.request - Schema Registry sees the tag, attaches
ResponseFormat{Type: "json_schema", Schema: ...}to the request - Provider sends structured output request to the API
- On deactivation, skills plugin emits
schema.deregister
2. Skill with File-Referenced Schema
For complex schemas, keep them in a separate JSON file.
Directory layout:
skills/
data-analysis/
SKILL.md
resources/
analysis.schema.json
SKILL.md:
---
name: data-analysis
description: Analyze datasets and produce structured findings.
output_schema_file: resources/analysis.schema.json
---
# Data Analysis
Analyze the provided dataset and return structured findings.
resources/analysis.schema.json:
{
"type": "object",
"required": ["summary", "findings", "recommendations"],
"properties": {
"summary": { "type": "string" },
"findings": {
"type": "array",
"items": {
"type": "object",
"required": ["metric", "value", "trend"],
"properties": {
"metric": { "type": "string" },
"value": { "type": "number" },
"trend": { "type": "string", "enum": ["up", "down", "stable"] }
}
}
},
"recommendations": {
"type": "array",
"items": { "type": "string" }
}
}
}
The path is resolved relative to the skill directory. The skills plugin loads and parses the file at activation time.
3. Embedder Requesting Structured Output
Embedders can set ResponseFormat directly on LLMRequest, bypassing the registry entirely.
// In your embedder code:
req := events.LLMRequest{
Messages: []events.Message{
{Role: "user", Content: "Analyze this resume..."},
},
ResponseFormat: &events.ResponseFormat{
Type: "json_schema",
Name: "candidate_score",
Schema: map[string]any{
"type": "object",
"required": []string{"name", "score", "reasoning"},
"properties": map[string]any{
"name": map[string]any{"type": "string"},
"score": map[string]any{"type": "integer", "minimum": 1, "maximum": 10},
"reasoning": map[string]any{"type": "string"},
},
},
Strict: true,
},
MaxTokens: 4096,
Stream: true,
}
_ = bus.Emit("llm.request", req)
Use direct ResponseFormat when:
- The schema is known at compile time and won’t change
- You don’t need the registry’s indirection
- You’re building a focused embedder app, not a plugin
4. Plugin Injecting Schema via before:llm.request
A custom plugin can dynamically select schemas based on conversation state.
func (p *MyPlugin) handleBeforeLLMRequest(event engine.Event[any]) {
vp, ok := event.Payload.(*engine.VetoablePayload)
if !ok {
return
}
req, ok := vp.Original.(*events.LLMRequest)
if !ok {
return
}
// Dynamic schema selection based on request context.
if p.shouldUseStructuredOutput(req) {
if req.Metadata == nil {
req.Metadata = make(map[string]any)
}
req.Metadata["_expects_schema"] = "my_plugin.output_schema"
}
}
This pattern works when:
- Schema varies based on conversation state or user input
- Plugin registers multiple schemas and picks one per request
- You want registry-based resolution but custom tagging logic
5. Belt-and-Suspenders with json_schema Gate
Enable both structured output and the json_schema gate for defense-in-depth.
plugins:
active:
- nexus.skills
- nexus.gate.json_schema
- nexus.llm.openai
- nexus.agent.react
nexus.gate.json_schema:
schema:
type: object
required: [entities]
properties:
entities:
type: array
max_retries: 2
How they interact:
- Schema Registry drives generation — provider sends structured output request
json_schemagate validates output — checks_structured_outputmetadata- When
_structured_outputistrue(provider enforced), the gate skips validation - When
_structured_outputis absent orfalse, the gate validates and retries as usual
This gives you native enforcement where available plus validation fallback everywhere else.
Provider Behavior
| Provider | json_object | json_schema | Metadata |
|---|---|---|---|
| OpenAI | Native response_format | Native response_format with strict mode | _structured_output: true |
| Anthropic | Not supported | Simulated via tool-use-as-schema | _structured_output: true |
| Unknown | Ignored | Ignored | No flag set |
Anthropic Simulation Details
Since Anthropic doesn’t support response_format, the provider simulates json_schema mode:
- Injects a synthetic tool
_structured_outputwith the schema as itsinput_schema - Forces
tool_choiceto{"type": "tool", "name": "_structured_output"} - Claude returns structured data as tool call arguments
- Provider unwraps tool arguments back into
LLMResponse.Content - During streaming, tool input deltas are emitted as content chunks
This overrides any existing ToolChoice on the request.