Structured Output

Lesson 6 · Strands Agents (TypeScript) · ~12 minutes

An agent that returns raw text is fine for chat. But in production, your downstream code needs typed data — objects it can validate, store, and route without string parsing. Strands solves this with Zod schemas on the agent constructor, giving you type-safe structured output with automatic validation and retries.

Why Structured Output Matters

Consider what happens without it: your agent produces a response, and your application code has to parse free-form text into usable data. You write regex, you handle edge cases, you pray the model doesn't change its formatting. One day it wraps JSON in markdown backticks. Another day it adds a preamble. Your parser breaks at 2am.

Structured output eliminates this entirely. You define a Zod schema, and Strands guarantees the agent's output conforms to it — or throws a typed error you can handle. The model is constrained at the API level to produce valid JSON matching your schema.

Defining Output Schemas

Pass a Zod schema as structuredOutputSchema on the Agent constructor. The agent's final response will be validated against this schema:

import { Agent } from '@strands-agents/sdk'
import { z } from 'zod'

const sentimentSchema = z.object({
  sentiment: z.enum(['positive', 'negative', 'neutral']),
  confidence: z.number().min(0).max(1),
  reasoning: z.string(),
  keywords: z.array(z.string()),
})

const analyzer = new Agent({
  name: 'sentiment-analyzer',
  model: 'us.anthropic.claude-sonnet-4-6-20250725-v1:0',
  systemPrompt: `Analyze the sentiment of the given text.
Identify the overall sentiment, your confidence level, reasoning, and key words.`,
  structuredOutputSchema: sentimentSchema,
})

The schema is both a runtime validator and a type definition. TypeScript infers the output type from the schema — no manual type declarations needed.

Accessing Typed Results

When you invoke an agent with a structured output schema, the result includes a structuredOutput field with full type inference:

const result = await analyzer.invoke(
  'The new feature launch exceeded all expectations. Users love it!'
)

// result.structuredOutput is fully typed:
// { sentiment: 'positive' | 'negative' | 'neutral', confidence: number, ... }
const { sentiment, confidence, reasoning, keywords } = result.structuredOutput

console.log(`Sentiment: ${sentiment} (${(confidence * 100).toFixed(0)}% confident)`)
console.log(`Keywords: ${keywords.join(', ')}`)
console.log(`Reasoning: ${reasoning}`)

No JSON.parse. No type assertions. No "I hope the model returned what I asked for." The schema enforces it, and TypeScript knows the shape at compile time.

Error Handling

When the model produces output that doesn't match your schema, Strands throws a StructuredOutputError. This is distinct from other errors — it means the model responded, but the response didn't validate:

import { StructuredOutputError } from '@strands-agents/sdk'

try {
  const result = await analyzer.invoke('Analyze this review...')
  // Use result.structuredOutput safely
} catch (error) {
  if (error instanceof StructuredOutputError) {
    console.error('Model output failed validation:', error.validationErrors)
    console.error('Raw output was:', error.rawOutput)
    // Decide: retry, fall back to a default, or escalate
  } else {
    // Other errors: network, rate limit, etc.
    throw error
  }
}
When does this actually fire?

In practice, StructuredOutputError is rare with well-designed schemas because Strands uses the model's constrained generation mode. But it can happen with complex Zod refinements that the model can't satisfy (e.g., a .refine() that checks business logic the model doesn't know about). Always handle it.

Auto-Retries with Zod Refinements

Zod supports .refine() for custom validation logic beyond type checks. Strands integrates with this: when a refinement fails, the framework can automatically retry the model with feedback about what went wrong.

const actionItemSchema = z.object({
  title: z.string().min(5, 'Title must be at least 5 characters'),
  assignee: z.string(),
  dueDate: z.string().refine(
    (d) => !isNaN(Date.parse(d)),
    'Must be a valid ISO date string'
  ),
  priority: z.enum(['high', 'medium', 'low']),
  context: z.string().refine(
    (c) => c.split(' ').length >= 10,
    'Context must be at least 10 words for downstream processing'
  ),
})

const extractor = new Agent({
  name: 'action-item-extractor',
  model: 'us.anthropic.claude-sonnet-4-6-20250725-v1:0',
  systemPrompt: 'Extract action items from meeting transcripts.',
  structuredOutputSchema: actionItemSchema,
  structuredOutputRetries: 2, // Retry up to 2 times on validation failure
})

When the model produces output where context is only 6 words, Strands catches the refinement failure and re-prompts the model with the error message: "Context must be at least 10 words for downstream processing." The model corrects itself and retries — no manual intervention.

Combining Structured Output with Tools

Structured output and tools work together naturally. The agent uses tools to gather information, then produces a structured result from what it found:

import { Agent, tool } from '@strands-agents/sdk'
import { z } from 'zod'

const lookupUser = tool({
  name: 'lookupUser',
  description: 'Look up a user by email address',
  schema: z.object({ email: z.string().email() }),
  handler: async ({ email }) => {
    // Simulate database lookup
    return { name: 'Alice Chen', role: 'engineer', team: 'Platform' }
  },
})

const lookupTickets = tool({
  name: 'lookupTickets',
  description: 'Get open tickets assigned to a user',
  schema: z.object({ userName: z.string() }),
  handler: async ({ userName }) => {
    return [
      { id: 'PLAT-123', title: 'Fix auth timeout', priority: 'high' },
      { id: 'PLAT-456', title: 'Update SDK docs', priority: 'low' },
    ]
  },
})

const reportSchema = z.object({
  userName: z.string(),
  team: z.string(),
  openTickets: z.number(),
  highPriorityCount: z.number(),
  summary: z.string(),
})

const statusReporter = new Agent({
  name: 'status-reporter',
  model: 'us.anthropic.claude-sonnet-4-6-20250725-v1:0',
  systemPrompt: `Look up the user and their tickets, then produce a status summary.`,
  tools: [lookupUser, lookupTickets],
  structuredOutputSchema: reportSchema,
})

const result = await statusReporter.invoke('Status report for alice@company.com')
// result.structuredOutput: { userName, team, openTickets, highPriorityCount, summary }

The agent loops through tools (lookup user → lookup tickets), then produces the structured output as its final response. The schema constrains only the final output — tool calls remain flexible.

Streaming + Structured Output

You can stream events from an agent with structured output. The events flow normally during execution (tool calls, intermediate reasoning), and the typed structuredOutput is available on the final result:

const result = await statusReporter.invoke('Status for alice@company.com', {
  streaming: true,
  onEvent: (event) => {
    switch (event.type) {
      case 'toolCall':
        console.log(`Calling: ${event.toolName}`)
        break
      case 'textChunk':
        // Intermediate reasoning (not the structured output)
        process.stdout.write(event.text)
        break
    }
  },
})

// After streaming completes, structured output is available
console.log('Report:', result.structuredOutput)
Streaming nuance

Structured output is available only after the stream completes — it's the final validated result. You can't incrementally build the typed object from stream chunks. Use streaming for progress feedback (tool calls, reasoning), not for partial structured data.

Practical Pattern: Meeting Action Item Extractor

Let's build a complete example: an agent that takes raw meeting transcript text and extracts structured action items with assignees, due dates, and priorities.

import { Agent } from '@strands-agents/sdk'
import { z } from 'zod'

// Define the output schema
const meetingOutputSchema = z.object({
  meetingTitle: z.string(),
  date: z.string(),
  attendees: z.array(z.string()),
  actionItems: z.array(z.object({
    id: z.number(),
    title: z.string().min(5),
    assignee: z.string(),
    dueDate: z.string().refine(
      (d) => !isNaN(Date.parse(d)),
      'Must be a valid ISO date'
    ),
    priority: z.enum(['high', 'medium', 'low']),
    context: z.string(),
  })),
  decisions: z.array(z.object({
    decision: z.string(),
    rationale: z.string(),
  })),
  nextMeeting: z.string().optional(),
})

// Create the agent
const meetingProcessor = new Agent({
  name: 'meeting-processor',
  model: 'us.anthropic.claude-sonnet-4-6-20250725-v1:0',
  systemPrompt: `You process meeting transcripts into structured data.
Extract:
- Meeting metadata (title, date, attendees)
- Action items with assignees, due dates, and priorities
- Key decisions with rationale
- Next meeting date if mentioned

Be precise with dates (ISO format). Infer priority from urgency language.
If no due date is mentioned, use 1 week from the meeting date.`,
  structuredOutputSchema: meetingOutputSchema,
  structuredOutputRetries: 2,
})

// Use it
const transcript = `
Team sync - July 3, 2026
Attendees: Alice, Bob, Carol

Alice: The auth service is timing out under load. We need to fix this before
the launch next Tuesday. Bob, can you take this?

Bob: Yes, I'll have a fix by Monday. I'll also update the load test suite.

Carol: I've decided we're going with Redis for the session cache instead of
DynamoDB. The latency numbers are 3x better for our access pattern.

Alice: Good call. Let's meet again Thursday to review Bob's fix.
`

const result = await meetingProcessor.invoke(transcript)
const { actionItems, decisions, nextMeeting } = result.structuredOutput

// Fully typed — send to your task management system
for (const item of actionItems) {
  console.log(`[${item.priority.toUpperCase()}] ${item.title}`)
  console.log(`  Assigned to: ${item.assignee}, due: ${item.dueDate}`)
}

// Decisions are structured too
for (const d of decisions) {
  console.log(`Decision: ${d.decision}`)
  console.log(`  Why: ${d.rationale}`)
}

The output is guaranteed to match your schema. Feed it directly to a project management API, store it in a database with typed columns, or pass it to another agent in a workflow. No parsing, no prayer.

Key Takeaways

An agent with structuredOutputRetries: 2 produces output where a Zod .refine() fails. What happens?
Correct. Strands feeds the Zod error message back to the model as context for the retry. The model sees exactly what failed ("Context must be at least 10 words") and can correct it. After exhausting retries, it throws StructuredOutputError.
With retries configured, Strands doesn't fail immediately. It takes the Zod error message, feeds it back to the model as "your output didn't validate because: [message]", and lets the model try again. Only after all retries are exhausted does it throw StructuredOutputError.
You want to stream tool-call progress to a UI while also getting a typed result. Which approach works?
Correct. Streaming and structured output compose: use the event stream for progress feedback (tool calls, reasoning chunks), and access the fully validated structuredOutput on the final result object once the invocation completes.
Streaming and structured output are compatible. Stream events give you real-time progress (tool calls, text chunks), and the final result object contains the validated structuredOutput. You can't build the structured object incrementally from chunks — it's only available at completion.
Your agent uses tools to fetch data AND has a structuredOutputSchema. When does the schema validation apply?
Correct. The structuredOutputSchema constrains only the final output. During the agent loop, tool calls happen freely — the model reasons, gathers data, and calls tools as needed. Only when the model produces its final response does Strands validate against the schema.
The schema is a constraint on the final output, not on intermediate steps. Tools operate freely during the agent loop. Once the model decides to produce its final response (stop calling tools), that response is validated against the Zod schema.
📖 Primary Source

Structured Output — Strands Agents Docs — Full API reference for structured output schemas, retries, and error handling. ~8 min read.

💬 Questions? Ask me about schema design for complex domains, retry strategies, combining structured output with multi-agent patterns, or migrating from raw JSON parsing to validated schemas.
← Back Next →