Skip to content

Commit 6902c22

Browse files
boginiclaude
andauthored
feat: add structured output support via --json-schema argument (#687)
* feat: add structured output support Add support for Agent SDK structured outputs. New input: json_schema Output: structured_output (JSON string) Access: fromJSON(steps.id.outputs.structured_output).field Docs: https://docs.claude.com/en/docs/agent-sdk/structured-outputs * rm unused * refactor: simplify structured outputs to use claude_args Remove json_schema input in favor of passing --json-schema flag directly in claude_args. This simplifies the interface by treating structured outputs like other CLI flags (--model, --max-turns, etc.) instead of as a special input that gets injected. Users now specify: claude_args: '--json-schema {...}' Instead of separate: json_schema: {...} πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * chore: remove unused json-schema util and revert version - Remove src/utils/json-schema.ts (no longer used after refactor) - Revert Claude Code version from 2.0.45 back to 2.0.42 πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e45f28f commit 6902c22

9 files changed

Lines changed: 730 additions & 2 deletions

File tree

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
name: Test Structured Outputs
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
pull_request:
8+
workflow_dispatch:
9+
10+
permissions:
11+
contents: read
12+
13+
jobs:
14+
test-basic-types:
15+
name: Test Basic Type Conversions
16+
runs-on: ubuntu-latest
17+
steps:
18+
- name: Checkout
19+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
20+
21+
- name: Test with explicit values
22+
id: test
23+
uses: ./base-action
24+
with:
25+
prompt: |
26+
Run this command: echo "test"
27+
28+
Then return EXACTLY these values:
29+
- text_field: "hello"
30+
- number_field: 42
31+
- boolean_true: true
32+
- boolean_false: false
33+
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
34+
claude_args: |
35+
--allowedTools Bash
36+
--json-schema '{"type":"object","properties":{"text_field":{"type":"string"},"number_field":{"type":"number"},"boolean_true":{"type":"boolean"},"boolean_false":{"type":"boolean"}},"required":["text_field","number_field","boolean_true","boolean_false"]}'
37+
38+
- name: Verify outputs
39+
run: |
40+
# Parse the structured_output JSON
41+
OUTPUT='${{ steps.test.outputs.structured_output }}'
42+
43+
# Test string pass-through
44+
TEXT_FIELD=$(echo "$OUTPUT" | jq -r '.text_field')
45+
if [ "$TEXT_FIELD" != "hello" ]; then
46+
echo "❌ String: expected 'hello', got '$TEXT_FIELD'"
47+
exit 1
48+
fi
49+
50+
# Test number β†’ string conversion
51+
NUMBER_FIELD=$(echo "$OUTPUT" | jq -r '.number_field')
52+
if [ "$NUMBER_FIELD" != "42" ]; then
53+
echo "❌ Number: expected '42', got '$NUMBER_FIELD'"
54+
exit 1
55+
fi
56+
57+
# Test boolean β†’ "true" conversion
58+
BOOLEAN_TRUE=$(echo "$OUTPUT" | jq -r '.boolean_true')
59+
if [ "$BOOLEAN_TRUE" != "true" ]; then
60+
echo "❌ Boolean true: expected 'true', got '$BOOLEAN_TRUE'"
61+
exit 1
62+
fi
63+
64+
# Test boolean β†’ "false" conversion
65+
BOOLEAN_FALSE=$(echo "$OUTPUT" | jq -r '.boolean_false')
66+
if [ "$BOOLEAN_FALSE" != "false" ]; then
67+
echo "❌ Boolean false: expected 'false', got '$BOOLEAN_FALSE'"
68+
exit 1
69+
fi
70+
71+
echo "βœ… All basic type conversions correct"
72+
73+
test-complex-types:
74+
name: Test Arrays and Objects
75+
runs-on: ubuntu-latest
76+
steps:
77+
- name: Checkout
78+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
79+
80+
- name: Test complex types
81+
id: test
82+
uses: ./base-action
83+
with:
84+
prompt: |
85+
Run: echo "ready"
86+
87+
Return EXACTLY:
88+
- items: ["apple", "banana", "cherry"]
89+
- config: {"key": "value", "count": 3}
90+
- empty_array: []
91+
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
92+
claude_args: |
93+
--allowedTools Bash
94+
--json-schema '{"type":"object","properties":{"items":{"type":"array","items":{"type":"string"}},"config":{"type":"object"},"empty_array":{"type":"array"}},"required":["items","config","empty_array"]}'
95+
96+
- name: Verify JSON stringification
97+
run: |
98+
# Parse the structured_output JSON
99+
OUTPUT='${{ steps.test.outputs.structured_output }}'
100+
101+
# Arrays should be JSON stringified
102+
if ! echo "$OUTPUT" | jq -e '.items | length == 3' > /dev/null; then
103+
echo "❌ Array not properly formatted"
104+
echo "$OUTPUT" | jq '.items'
105+
exit 1
106+
fi
107+
108+
# Objects should be JSON stringified
109+
if ! echo "$OUTPUT" | jq -e '.config.key == "value"' > /dev/null; then
110+
echo "❌ Object not properly formatted"
111+
echo "$OUTPUT" | jq '.config'
112+
exit 1
113+
fi
114+
115+
# Empty arrays should work
116+
if ! echo "$OUTPUT" | jq -e '.empty_array | length == 0' > /dev/null; then
117+
echo "❌ Empty array not properly formatted"
118+
echo "$OUTPUT" | jq '.empty_array'
119+
exit 1
120+
fi
121+
122+
echo "βœ… All complex types handled correctly"
123+
124+
test-edge-cases:
125+
name: Test Edge Cases
126+
runs-on: ubuntu-latest
127+
steps:
128+
- name: Checkout
129+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
130+
131+
- name: Test edge cases
132+
id: test
133+
uses: ./base-action
134+
with:
135+
prompt: |
136+
Run: echo "test"
137+
138+
Return EXACTLY:
139+
- zero: 0
140+
- empty_string: ""
141+
- negative: -5
142+
- decimal: 3.14
143+
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
144+
claude_args: |
145+
--allowedTools Bash
146+
--json-schema '{"type":"object","properties":{"zero":{"type":"number"},"empty_string":{"type":"string"},"negative":{"type":"number"},"decimal":{"type":"number"}},"required":["zero","empty_string","negative","decimal"]}'
147+
148+
- name: Verify edge cases
149+
run: |
150+
# Parse the structured_output JSON
151+
OUTPUT='${{ steps.test.outputs.structured_output }}'
152+
153+
# Zero should be "0", not empty or falsy
154+
ZERO=$(echo "$OUTPUT" | jq -r '.zero')
155+
if [ "$ZERO" != "0" ]; then
156+
echo "❌ Zero: expected '0', got '$ZERO'"
157+
exit 1
158+
fi
159+
160+
# Empty string should be empty (not "null" or missing)
161+
EMPTY_STRING=$(echo "$OUTPUT" | jq -r '.empty_string')
162+
if [ "$EMPTY_STRING" != "" ]; then
163+
echo "❌ Empty string: expected '', got '$EMPTY_STRING'"
164+
exit 1
165+
fi
166+
167+
# Negative numbers should work
168+
NEGATIVE=$(echo "$OUTPUT" | jq -r '.negative')
169+
if [ "$NEGATIVE" != "-5" ]; then
170+
echo "❌ Negative: expected '-5', got '$NEGATIVE'"
171+
exit 1
172+
fi
173+
174+
# Decimals should preserve precision
175+
DECIMAL=$(echo "$OUTPUT" | jq -r '.decimal')
176+
if [ "$DECIMAL" != "3.14" ]; then
177+
echo "❌ Decimal: expected '3.14', got '$DECIMAL'"
178+
exit 1
179+
fi
180+
181+
echo "βœ… All edge cases handled correctly"
182+
183+
test-name-sanitization:
184+
name: Test Output Name Sanitization
185+
runs-on: ubuntu-latest
186+
steps:
187+
- name: Checkout
188+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
189+
190+
- name: Test special characters in field names
191+
id: test
192+
uses: ./base-action
193+
with:
194+
prompt: |
195+
Run: echo "test"
196+
Return EXACTLY: {test-result: "passed", item_count: 10}
197+
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
198+
claude_args: |
199+
--allowedTools Bash
200+
--json-schema '{"type":"object","properties":{"test-result":{"type":"string"},"item_count":{"type":"number"}},"required":["test-result","item_count"]}'
201+
202+
- name: Verify sanitized names work
203+
run: |
204+
# Parse the structured_output JSON
205+
OUTPUT='${{ steps.test.outputs.structured_output }}'
206+
207+
# Hyphens should be preserved in the JSON
208+
TEST_RESULT=$(echo "$OUTPUT" | jq -r '.["test-result"]')
209+
if [ "$TEST_RESULT" != "passed" ]; then
210+
echo "❌ Hyphenated name failed: expected 'passed', got '$TEST_RESULT'"
211+
exit 1
212+
fi
213+
214+
# Underscores should work
215+
ITEM_COUNT=$(echo "$OUTPUT" | jq -r '.item_count')
216+
if [ "$ITEM_COUNT" != "10" ]; then
217+
echo "❌ Underscore name failed: expected '10', got '$ITEM_COUNT'"
218+
exit 1
219+
fi
220+
221+
echo "βœ… Name sanitization works"
222+
223+
test-execution-file-structure:
224+
name: Test Execution File Format
225+
runs-on: ubuntu-latest
226+
steps:
227+
- name: Checkout
228+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
229+
230+
- name: Run with structured output
231+
id: test
232+
uses: ./base-action
233+
with:
234+
prompt: "Run: echo 'complete'. Return: {done: true}"
235+
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
236+
claude_args: |
237+
--allowedTools Bash
238+
--json-schema '{"type":"object","properties":{"done":{"type":"boolean"}},"required":["done"]}'
239+
240+
- name: Verify execution file contains structured_output
241+
run: |
242+
FILE="${{ steps.test.outputs.execution_file }}"
243+
244+
# Check file exists
245+
if [ ! -f "$FILE" ]; then
246+
echo "❌ Execution file missing"
247+
exit 1
248+
fi
249+
250+
# Check for structured_output field
251+
if ! jq -e '.[] | select(.type == "result") | .structured_output' "$FILE" > /dev/null; then
252+
echo "❌ No structured_output in execution file"
253+
cat "$FILE"
254+
exit 1
255+
fi
256+
257+
# Verify the actual value
258+
DONE=$(jq -r '.[] | select(.type == "result") | .structured_output.done' "$FILE")
259+
if [ "$DONE" != "true" ]; then
260+
echo "❌ Wrong value in execution file"
261+
exit 1
262+
fi
263+
264+
echo "βœ… Execution file format correct"
265+
266+
test-summary:
267+
name: Summary
268+
runs-on: ubuntu-latest
269+
needs:
270+
- test-basic-types
271+
- test-complex-types
272+
- test-edge-cases
273+
- test-name-sanitization
274+
- test-execution-file-structure
275+
if: always()
276+
steps:
277+
- name: Generate Summary
278+
run: |
279+
echo "# Structured Output Tests (Optimized)" >> $GITHUB_STEP_SUMMARY
280+
echo "" >> $GITHUB_STEP_SUMMARY
281+
echo "Fast, deterministic tests using explicit prompts" >> $GITHUB_STEP_SUMMARY
282+
echo "" >> $GITHUB_STEP_SUMMARY
283+
echo "| Test | Result |" >> $GITHUB_STEP_SUMMARY
284+
echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
285+
echo "| Basic Types | ${{ needs.test-basic-types.result == 'success' && 'βœ… PASS' || '❌ FAIL' }} |" >> $GITHUB_STEP_SUMMARY
286+
echo "| Complex Types | ${{ needs.test-complex-types.result == 'success' && 'βœ… PASS' || '❌ FAIL' }} |" >> $GITHUB_STEP_SUMMARY
287+
echo "| Edge Cases | ${{ needs.test-edge-cases.result == 'success' && 'βœ… PASS' || '❌ FAIL' }} |" >> $GITHUB_STEP_SUMMARY
288+
echo "| Name Sanitization | ${{ needs.test-name-sanitization.result == 'success' && 'βœ… PASS' || '❌ FAIL' }} |" >> $GITHUB_STEP_SUMMARY
289+
echo "| Execution File | ${{ needs.test-execution-file-structure.result == 'success' && 'βœ… PASS' || '❌ FAIL' }} |" >> $GITHUB_STEP_SUMMARY
290+
291+
# Check if all passed
292+
ALL_PASSED=${{
293+
needs.test-basic-types.result == 'success' &&
294+
needs.test-complex-types.result == 'success' &&
295+
needs.test-edge-cases.result == 'success' &&
296+
needs.test-name-sanitization.result == 'success' &&
297+
needs.test-execution-file-structure.result == 'success'
298+
}}
299+
300+
if [ "$ALL_PASSED" = "true" ]; then
301+
echo "" >> $GITHUB_STEP_SUMMARY
302+
echo "## βœ… All Tests Passed" >> $GITHUB_STEP_SUMMARY
303+
else
304+
echo "" >> $GITHUB_STEP_SUMMARY
305+
echo "## ❌ Some Tests Failed" >> $GITHUB_STEP_SUMMARY
306+
exit 1
307+
fi

β€ŽREADME.mdβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ A general-purpose [Claude Code](https://claude.ai/code) action for GitHub PRs an
1313
- πŸ’¬ **PR/Issue Integration**: Works seamlessly with GitHub comments and PR reviews
1414
- πŸ› οΈ **Flexible Tool Access**: Access to GitHub APIs and file operations (additional tools can be enabled via configuration)
1515
- πŸ“‹ **Progress Tracking**: Visual progress indicators with checkboxes that dynamically update as Claude completes tasks
16+
- πŸ“Š **Structured Outputs**: Get validated JSON results that automatically become GitHub Action outputs for complex automations
1617
- πŸƒ **Runs on Your Infrastructure**: The action executes entirely on your own GitHub runner (Anthropic API calls go to your chosen provider)
1718
- βš™οΈ **Simplified Configuration**: Unified `prompt` and `claude_args` inputs provide clean, powerful configuration aligned with Claude Code SDK
1819

β€Žaction.ymlβ€Ž

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,9 @@ outputs:
124124
github_token:
125125
description: "The GitHub token used by the action (Claude App token if available)"
126126
value: ${{ steps.prepare.outputs.github_token }}
127+
structured_output:
128+
description: "JSON string containing all structured output fields when --json-schema is provided in claude_args. Use fromJSON() to parse: fromJSON(steps.id.outputs.structured_output).field_name"
129+
value: ${{ steps.claude-code.outputs.structured_output }}
127130

128131
runs:
129132
using: "composite"

β€Žbase-action/action.ymlβ€Ž

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ outputs:
7575
execution_file:
7676
description: "Path to the JSON file containing Claude Code execution log"
7777
value: ${{ steps.run_claude.outputs.execution_file }}
78+
structured_output:
79+
description: "JSON string containing all structured output fields when --json-schema is provided in claude_args (use fromJSON() or jq to parse)"
80+
value: ${{ steps.run_claude.outputs.structured_output }}
7881

7982
runs:
8083
using: "composite"

0 commit comments

Comments
Β (0)