# Ag2 Structured Output

> Get a typed Python value back from an AG2 `Agent` instead of free text. Pass `response_schema=` (a Pydantic model, dataclass, primitive, union, `ResponseSchema`, or `@response_schema` validator) and read the parsed result via `await reply.content()`. Use when the user wants validated structured output, classification, extraction, or scoring. Covers `ResponseSchema`, `@response_schema`, `PromptedSchema` (for providers without native structured output), per-turn override, validation retries, and primitive embedding.

- Skill: `ag2ai/ag2-structured-output` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add ag2ai/ag2-structured-output`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ag2ai/ag2-structured-output/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- License: Apache-2.0
- Author: ag2ai (https://skillmd.com/u/ag2ai)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ag2ai/ag2-structured-output

---


# Structured output

## When to use

- The user wants a Pydantic model, dataclass, dict, primitive, or union back — not a string.
- They're doing classification, extraction, scoring, normalisation, or anything where downstream code parses the reply.
- They want automatic retry on validation failure.

## 60-second recipe

```python
from pydantic import BaseModel, Field
from typing import Annotated

from ag2 import Agent
from ag2.config import OpenAIConfig

class TicketTriage(BaseModel):
    category: Annotated[str, Field(description="e.g. billing, bug, account_access")]
    urgency: Annotated[str, Field(description="low, medium, or high")]
    summary_one_line: Annotated[str, Field(description="Max 120 characters", max_length=120)]

agent = Agent(
    "triage",
    prompt="You triage support messages. Be conservative with urgency.",
    config=OpenAIConfig(model="gpt-4o-mini"),
    response_schema=TicketTriage,
)

reply = await agent.ask("I was charged twice and can't export reports. Quarter close blocked.")
triage = await reply.content()      # → typed TicketTriage
print(triage.category, triage.urgency)
```

`reply.body` is still the raw model text; `await reply.content()` runs validation and returns the parsed value. If validation fails, `content()` raises (e.g. `pydantic.ValidationError`).

## Schema types you can pass

| Type | What you get |
|---|---|
| Primitive (`int`, `float`, `bool`) | Bare value, framework wraps in `{"data": ...}` for the API |
| `dataclass` | Instance of the dataclass |
| Pydantic `BaseModel` | Instance of the model |
| Union (`int \| str`, `(int, str)`) | One of the alternatives |
| `dict[K, V]`, `TypedDict` | Validated dict |
| `ResponseSchema(...)` | Same as above, with explicit `name` / `description` for the provider |
| `@response_schema` callable | Custom validation/parsing logic |
| `PromptedSchema(inner)` | Schema injected into the system prompt for providers without native structured output |

## `ResponseSchema` — name your payload

Helps the provider treat the structured output as a named contract:

```python
from ag2 import Agent, ResponseSchema

schema = ResponseSchema(int | str, name="ByteWidth", description="Number of bits in one byte.")
agent = Agent("assistant", config=config, response_schema=schema)
```

## `@response_schema` — custom validation

For clamping, regex cleanup, decoding wrapped JSON, or combining fields:

```python
from ag2 import Agent, response_schema

@response_schema
def parse_rating(content: str) -> int:
    """Parse a rating and clamp to 1–5."""
    return max(1, min(5, int(content)))

agent = Agent("assistant", config=config, response_schema=parse_rating)
```

Multi-parameter form synthesises a JSON object schema from the parameter names:

```python
from typing import Annotated
from pydantic import Field
from ag2 import response_schema

@response_schema
def extract_listing(
    title: Annotated[str, Field(description="Product name")],
    price_usd: Annotated[float, Field(description="Price in USD", ge=0)],
    in_stock: Annotated[bool, Field(description="True if it ships now")],
) -> dict:
    return {"title": title, "price_usd": price_usd, "in_stock": in_stock}
```

The function also participates in dependency injection — `Context`, `Variable`, `Inject`, `Depends` work the same way as in tools (and don't appear in the JSON schema).

Async validators are supported:

```python
import json

@response_schema
async def fetch_and_validate(content: str) -> dict:
    data = json.loads(content)
    data["validated"] = True
    return data
```

## `PromptedSchema` — for providers without native structured output

Injects the JSON schema into the system prompt instead of using `response_format`:

```python
from ag2 import Agent, PromptedSchema

agent = Agent("assistant", config=config, response_schema=PromptedSchema(int))
```

Wraps any inner schema (type, `ResponseSchema`, `@response_schema` callable). The validation logic stays the same; only the wire format changes.

Custom prompt template:

```python
PromptedSchema(int, prompt_template="Reply with JSON matching this schema:\n{schema}")
```

## Per-turn override

```python
agent = Agent("assistant", config=config)

turn = await agent.ask("How many seconds in a minute?", response_schema=int)
print(await turn.content())   # 60

turn2 = await turn.ask("Say hello.")     # back to default (no schema)
```

Pass `response_schema=None` to drop a schema set on the agent for one call.

## Retries

When validation fails, automatically re-ask the model:

```python
result = await reply.content(retries=3)   # initial + up to 3 re-asks
result = await reply.content(retries=math.inf)   # interactive only — could loop forever
```

The validation error is sent back to the model as a follow-up so it can correct itself.

## Primitive embedding (`embed`)

Bare primitives (`int`, `float`, `bool`, `list[T]`, primitive unions) get wrapped in `{"data": ...}` by default — most structured-output APIs handle objects more reliably than bare values. `content()` transparently unwraps. Opt out:

```python
ResponseSchema(int, name="RawInt", embed=False)              # model must produce a bare 42
@response_schema(embed=False)
def parse_rating(value: int) -> int: ...
```

## Going deeper

- Working starter: `assets/recipe_builder.py` (mirrors `code_examples/02`) — Pydantic model + `@tool` + `response_schema=`.
- Full reference: `website/docs/user-guide/structured_output.mdx` — covers every schema type, multi-param `@response_schema`, `Field` constraints, `PromptedSchema`, retries, embedding semantics.

## Common pitfalls

- **Reading `reply.body` when you wanted typed output** — `reply.body` is the raw text. `await reply.content()` does the parsing.
- **Forgetting `await` on `content()`** — it's async; you'll get a coroutine, not the value.
- **No `description` in the Pydantic field** — the LLM may guess what to put in each field. Add a `Field(description=...)` for every non-obvious key.
- **Provider doesn't support native structured output** — wrap with `PromptedSchema(...)` rather than fighting the API.
- **`retries=math.inf` in production** — will loop forever on a model that can't comply. Use a finite count.
- **Per-turn override is single-turn** — passing `response_schema=int` to one `ask()` doesn't change the agent's default. The next turn returns to whatever was set on the constructor.

