Skip to content

Commit 50d6840

Browse files
authored
feat: add structured output support (#340)
Add structured output support to Python SDK. ## Usage ```python from claude_agent_sdk import query, ClaudeAgentOptions schema = { "type": "object", "properties": {"count": {"type": "number"}}, "required": ["count"] } async for msg in query( prompt="Count files in src/", options=ClaudeAgentOptions( output_format={"type": "json_schema", "schema": schema} ) ): if hasattr(msg, 'structured_output'): print(msg.structured_output) ``` ## Documentation https://docs.claude.com/en/docs/agent-sdk/structured-outputs ## Tests - Unit tests: `tests/test_integration.py::TestIntegration::test_structured_output` - E2E tests: `e2e-tests/test_structured_output.py` (4 tests)
1 parent ff425b2 commit 50d6840

File tree

4 files changed

+226
-5
lines changed

4 files changed

+226
-5
lines changed
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
"""End-to-end tests for structured output with real Claude API calls.
2+
3+
These tests verify that the output_schema feature works correctly by making
4+
actual API calls to Claude with JSON Schema validation.
5+
"""
6+
7+
import tempfile
8+
9+
import pytest
10+
11+
from claude_agent_sdk import (
12+
ClaudeAgentOptions,
13+
ResultMessage,
14+
query,
15+
)
16+
17+
18+
@pytest.mark.e2e
19+
@pytest.mark.asyncio
20+
async def test_simple_structured_output():
21+
"""Test structured output with file counting requiring tool use."""
22+
23+
# Define schema for file analysis
24+
schema = {
25+
"type": "object",
26+
"properties": {
27+
"file_count": {"type": "number"},
28+
"has_tests": {"type": "boolean"},
29+
"test_file_count": {"type": "number"},
30+
},
31+
"required": ["file_count", "has_tests"],
32+
}
33+
34+
options = ClaudeAgentOptions(
35+
output_format={"type": "json_schema", "schema": schema},
36+
permission_mode="acceptEdits",
37+
cwd=".", # Use current directory
38+
)
39+
40+
# Agent must use Glob/Bash to count files
41+
result_message = None
42+
async for message in query(
43+
prompt="Count how many Python files are in src/claude_agent_sdk/ and check if there are any test files. Use tools to explore the filesystem.",
44+
options=options,
45+
):
46+
if isinstance(message, ResultMessage):
47+
result_message = message
48+
49+
# Verify result
50+
assert result_message is not None, "No result message received"
51+
assert not result_message.is_error, f"Query failed: {result_message.result}"
52+
assert result_message.subtype == "success"
53+
54+
# Verify structured output is present and valid
55+
assert result_message.structured_output is not None, "No structured output in result"
56+
assert "file_count" in result_message.structured_output
57+
assert "has_tests" in result_message.structured_output
58+
assert isinstance(result_message.structured_output["file_count"], (int, float))
59+
assert isinstance(result_message.structured_output["has_tests"], bool)
60+
61+
# Should find Python files in src/
62+
assert result_message.structured_output["file_count"] > 0
63+
64+
65+
@pytest.mark.e2e
66+
@pytest.mark.asyncio
67+
async def test_nested_structured_output():
68+
"""Test structured output with nested objects and arrays."""
69+
70+
# Define a schema with nested structure
71+
schema = {
72+
"type": "object",
73+
"properties": {
74+
"analysis": {
75+
"type": "object",
76+
"properties": {
77+
"word_count": {"type": "number"},
78+
"character_count": {"type": "number"},
79+
},
80+
"required": ["word_count", "character_count"],
81+
},
82+
"words": {
83+
"type": "array",
84+
"items": {"type": "string"},
85+
},
86+
},
87+
"required": ["analysis", "words"],
88+
}
89+
90+
options = ClaudeAgentOptions(
91+
output_format={"type": "json_schema", "schema": schema},
92+
permission_mode="acceptEdits",
93+
)
94+
95+
result_message = None
96+
async for message in query(
97+
prompt="Analyze this text: 'Hello world'. Provide word count, character count, and list of words.",
98+
options=options,
99+
):
100+
if isinstance(message, ResultMessage):
101+
result_message = message
102+
103+
# Verify result
104+
assert result_message is not None
105+
assert not result_message.is_error
106+
assert result_message.structured_output is not None
107+
108+
# Check nested structure
109+
output = result_message.structured_output
110+
assert "analysis" in output
111+
assert "words" in output
112+
assert output["analysis"]["word_count"] == 2
113+
assert output["analysis"]["character_count"] == 11 # "Hello world"
114+
assert len(output["words"]) == 2
115+
116+
117+
@pytest.mark.e2e
118+
@pytest.mark.asyncio
119+
async def test_structured_output_with_enum():
120+
"""Test structured output with enum constraints requiring code analysis."""
121+
122+
schema = {
123+
"type": "object",
124+
"properties": {
125+
"has_tests": {"type": "boolean"},
126+
"test_framework": {
127+
"type": "string",
128+
"enum": ["pytest", "unittest", "nose", "unknown"],
129+
},
130+
"test_count": {"type": "number"},
131+
},
132+
"required": ["has_tests", "test_framework"],
133+
}
134+
135+
options = ClaudeAgentOptions(
136+
output_format={"type": "json_schema", "schema": schema},
137+
permission_mode="acceptEdits",
138+
cwd=".",
139+
)
140+
141+
result_message = None
142+
async for message in query(
143+
prompt="Search for test files in the tests/ directory. Determine which test framework is being used (pytest/unittest/nose) and count how many test files exist. Use Grep to search for framework imports.",
144+
options=options,
145+
):
146+
if isinstance(message, ResultMessage):
147+
result_message = message
148+
149+
# Verify result
150+
assert result_message is not None
151+
assert not result_message.is_error
152+
assert result_message.structured_output is not None
153+
154+
# Check enum values are valid
155+
output = result_message.structured_output
156+
assert output["test_framework"] in ["pytest", "unittest", "nose", "unknown"]
157+
assert isinstance(output["has_tests"], bool)
158+
159+
# This repo uses pytest
160+
assert output["has_tests"] is True
161+
assert output["test_framework"] == "pytest"
162+
163+
164+
@pytest.mark.e2e
165+
@pytest.mark.asyncio
166+
async def test_structured_output_with_tools():
167+
"""Test structured output when agent uses tools."""
168+
169+
# Schema for file analysis
170+
schema = {
171+
"type": "object",
172+
"properties": {
173+
"file_count": {"type": "number"},
174+
"has_readme": {"type": "boolean"},
175+
},
176+
"required": ["file_count", "has_readme"],
177+
}
178+
179+
options = ClaudeAgentOptions(
180+
output_format={"type": "json_schema", "schema": schema},
181+
permission_mode="acceptEdits",
182+
cwd=tempfile.gettempdir(), # Cross-platform temp directory
183+
)
184+
185+
result_message = None
186+
async for message in query(
187+
prompt="Count how many files are in the current directory and check if there's a README file. Use tools as needed.",
188+
options=options,
189+
):
190+
if isinstance(message, ResultMessage):
191+
result_message = message
192+
193+
# Verify result
194+
assert result_message is not None
195+
assert not result_message.is_error
196+
assert result_message.structured_output is not None
197+
198+
# Check structure
199+
output = result_message.structured_output
200+
assert "file_count" in output
201+
assert "has_readme" in output
202+
assert isinstance(output["file_count"], (int, float))
203+
assert isinstance(output["has_readme"], bool)
204+
assert output["file_count"] >= 0 # Should be non-negative

src/claude_agent_sdk/_internal/message_parser.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ def parse_message(data: dict[str, Any]) -> Message:
149149
total_cost_usd=data.get("total_cost_usd"),
150150
usage=data.get("usage"),
151151
result=data.get("result"),
152+
structured_output=data.get("structured_output"),
152153
)
153154
except KeyError as e:
154155
raise MessageParseError(

src/claude_agent_sdk/_internal/transport/subprocess_cli.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -215,19 +215,31 @@ def _build_command(self) -> list[str]:
215215
# Flag with value
216216
cmd.extend([f"--{flag}", str(value)])
217217

218+
if self._options.max_thinking_tokens is not None:
219+
cmd.extend(
220+
["--max-thinking-tokens", str(self._options.max_thinking_tokens)]
221+
)
222+
223+
# Extract schema from output_format structure if provided
224+
# Expected: {"type": "json_schema", "schema": {...}}
225+
if (
226+
self._options.output_format is not None
227+
and isinstance(self._options.output_format, dict)
228+
and self._options.output_format.get("type") == "json_schema"
229+
):
230+
schema = self._options.output_format.get("schema")
231+
if schema is not None:
232+
cmd.extend(["--json-schema", json.dumps(schema)])
233+
218234
# Add prompt handling based on mode
235+
# IMPORTANT: This must come AFTER all flags because everything after "--" is treated as arguments
219236
if self._is_streaming:
220237
# Streaming mode: use --input-format stream-json
221238
cmd.extend(["--input-format", "stream-json"])
222239
else:
223240
# String mode: use --print with the prompt
224241
cmd.extend(["--print", "--", str(self._prompt)])
225242

226-
if self._options.max_thinking_tokens is not None:
227-
cmd.extend(
228-
["--max-thinking-tokens", str(self._options.max_thinking_tokens)]
229-
)
230-
231243
# Check if command line is too long (Windows limitation)
232244
cmd_str = " ".join(cmd)
233245
if len(cmd_str) > _CMD_LENGTH_LIMIT and self._options.agents:

src/claude_agent_sdk/types.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,7 @@ class ResultMessage:
492492
total_cost_usd: float | None = None
493493
usage: dict[str, Any] | None = None
494494
result: str | None = None
495+
structured_output: Any = None
495496

496497

497498
@dataclass
@@ -558,6 +559,9 @@ class ClaudeAgentOptions:
558559
plugins: list[SdkPluginConfig] = field(default_factory=list)
559560
# Max tokens for thinking blocks
560561
max_thinking_tokens: int | None = None
562+
# Output format for structured outputs (matches Messages API structure)
563+
# Example: {"type": "json_schema", "schema": {"type": "object", "properties": {...}}}
564+
output_format: dict[str, Any] | None = None
561565

562566

563567
# SDK Control Protocol

0 commit comments

Comments
 (0)