Skip to content

feat: add structured output support#682

Closed
bogini wants to merge 1 commit intomainfrom
inigo/structured-outputs
Closed

feat: add structured output support#682
bogini wants to merge 1 commit intomainfrom
inigo/structured-outputs

Conversation

@bogini
Copy link
Contributor

@bogini bogini commented Nov 18, 2025

Add support for Agent SDK structured outputs.

New Feature

  • Input: json_schema - JSON schema for validated outputs
  • Behavior: Auto-sets GitHub Action outputs for each field

Documentation

https://docs.claude.com/en/docs/agent-sdk/structured-outputs

Examples

  • examples/test-failure-analysis.yml - Auto-retry flaky tests
  • .github/workflows/test-structured-output.yml - Integration tests

Tests

  • 26 new unit tests
  • 5 integration test jobs
  • All 480 tests passing

@bogini bogini force-pushed the inigo/structured-outputs branch from 5541552 to 875e0ed Compare November 18, 2025 17:40
json_schema:
description: "JSON schema for structured output validation. When provided, Claude will return validated JSON matching this schema, and the action will automatically set GitHub Action outputs for each field."
required: false
default: ""
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The json_schema input should also be added to base-action/action.yml. Currently, the base action can't be used directly with structured outputs because it lacks this input definition.

Additionally, consider documenting in the description that this input creates dynamic outputs for each schema field (e.g., "When provided, automatically sets GitHub Action outputs for each field - access via steps.id.outputs.field_name").

default:
return JSON.stringify(value);
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
}
default:
return value === undefined ? "undefined" : String(value);

The default case has a bug: JSON.stringify(undefined), JSON.stringify(Symbol()), and JSON.stringify(Function) all return undefined (not a string), which would cause runtime errors when passed to core.setOutput().

Recommend explicitly handling these cases or using String(value) as a fallback.

for (const [key, value] of entries) {
const sanitizedKey = sanitizeOutputName(key);
if (!sanitizedKey) {
core.warning(`Skipping invalid output key: "${key}"`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check can never trigger for non-empty keys because sanitizeOutputName() replaces invalid characters with underscores, so it never returns an empty string unless the input was already empty.

Edge case: A key like "@@@@" becomes "____" and passes through. Consider adding validation:

if (!sanitizedKey || /^_+$/.test(sanitizedKey) || sanitizedKey !== key) {
  core.warning(`Output key "${key}" was sanitized to "${sanitizedKey}"`);
}

executionFile: string,
): Promise<void> {
try {
const content = await readFile(executionFile, "utf-8");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Performance opportunity: The execution file is parsed twice:

  1. At lines 382-385 when creating the execution file
  2. Here when extracting structured outputs

For large outputs (1-10MB), this adds 20-500ms overhead. Consider passing the already-parsed messages array instead of re-reading the file:

// After line 385, parse once:
const messages = JSON.parse(jsonOutput);
await writeFile(EXECUTION_FILE, jsonOutput);

// Then at line 396:
if (process.env.JSON_SCHEMA) {
  await parseAndSetStructuredOutputs(messages); // Pass array, not file path
}

default:
return JSON.stringify(value);
}
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage gap: These tests duplicate the implementation logic inline instead of testing the actual functions. If the real sanitizeOutputName() or convertToString() implementations change, these tests will still pass.

Consider either:

  1. Exporting these functions and testing them directly, or
  2. Testing them indirectly through parseAndSetStructuredOutputs() with mocked core.setOutput()

Also missing tests for:

  • undefined, Symbol, and Function values in convertToString() (would currently cause errors)
  • File read errors (ENOENT)
  • Empty string keys
  • Large outputs (1000+ fields)

core.setOutput("execution_file", EXECUTION_FILE);

// Parse and set structured outputs only if user provided json_schema
if (process.env.JSON_SCHEMA) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error handling issue: After setting conclusion: "success" at line 393, the structured output parsing at line 397 can call core.setFailed(), creating a confusing state.

Consider either:

  1. Moving structured output parsing before setting the conclusion
  2. Making parseAndSetStructuredOutputs() return a boolean and handling failure appropriately

Also, the error handling for file processing differs between success (warns at line 384) and failure paths (silently ignores) - these should be consistent.

// Parse and set structured outputs only if user provided json_schema
if (process.env.JSON_SCHEMA) {
await parseAndSetStructuredOutputs(EXECUTION_FILE);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical Issue: Missing Environment Variable

The JSON_SCHEMA environment variable is checked here, but it's not being passed to the base-action in the main action.yml. Looking at line 181 of action.yml, JSON_SCHEMA is only passed to the prepare step, not to the base-action run step.

This means structured outputs won't work when using the base-action standalone. Need to add to the base-action environment:

- name: Run Base Action
  env:
    JSON_SCHEMA: ${{ inputs.json_schema }}

);

if (!result?.structured_output) {
core.setFailed(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error Handling Issue

Calling core.setFailed() doesn't stop execution - the function returns but the workflow may continue to subsequent steps. Should throw an error to ensure proper failure propagation:

if (!result?.structured_output) {
  const error = new Error(
    "json_schema was provided but Claude did not return structured_output. " +
    "The schema may be invalid or Claude failed to call the StructuredOutput tool."
  );
  core.setFailed(error.message);
  throw error; // Ensures the action fails properly
}

// Add JSON schema if provided
const jsonSchema = process.env.JSON_SCHEMA || "";
if (jsonSchema) {
const escapedSchema = jsonSchema.replace(/'/g, "'\\''");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security: Command Injection Risk

While the single-quote escaping is a standard technique, passing user-controlled input through shell command construction is risky. Consider using an environment variable or file-based approach instead:

// Option 1: Environment variable
env: {
  CLAUDE_JSON_SCHEMA: jsonSchema,
}

// Option 2: Temporary file
const schemaPath = `${process.env.RUNNER_TEMP}/json-schema.json`;
await writeFile(schemaPath, jsonSchema);
claudeArgs += ` --json-schema-file "${schemaPath}"`;

Also recommend validating it's valid JSON first:

try {
  JSON.parse(jsonSchema);
} catch (e) {
  throw new Error("Invalid JSON schema provided");
}

executionFile: string,
): Promise<void> {
try {
const content = await readFile(executionFile, "utf-8");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Performance: Unnecessary File I/O

The execution file was just created from the output variable that's already in memory (line 385). Reading it back from disk adds unnecessary I/O overhead (10-50ms for typical outputs).

Consider passing the parsed messages array directly:

// After parsing output for execution file (around line 382)
const messages = JSON.parse(jsonOutput) as ExecutionMessage[];
await writeFile(EXECUTION_FILE, jsonOutput);

// Later, pass messages directly (line 396)
if (process.env.JSON_SCHEMA) {
  await parseAndSetStructuredOutputs(messages); // Instead of EXECUTION_FILE
}

Update function signature:

async function parseAndSetStructuredOutputs(
  messages: ExecutionMessage[],
): Promise<void>

continue;
}

const stringValue = convertToString(value);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing Output Size Validation

GitHub Actions has a 1MB limit per output field. Large structured outputs could exceed this and cause workflow failures. Recommend adding validation:

const MAX_OUTPUT_SIZE = 100000; // 100KB per field

const stringValue = convertToString(value);

if (stringValue.length > MAX_OUTPUT_SIZE) {
  core.warning(
    `Output ${sanitizedKey} exceeds safe size (${stringValue.length} bytes), truncating to ${MAX_OUTPUT_SIZE} bytes`
  );
  core.setOutput(sanitizedKey, stringValue.substring(0, MAX_OUTPUT_SIZE));
} else {
  core.setOutput(sanitizedKey, stringValue);
}

const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`;
const BASE_ARGS = ["--verbose", "--output-format", "stream-json"];

type ExecutionMessage = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type Safety: Weak Type Definition

The ExecutionMessage type is too permissive. Consider using discriminated unions for better type safety:

type ExecutionMessage = 
  | {
      type: "system";
      subtype: "init" | "shutdown";
      model?: string;
    }
  | {
      type: "result";
      cost_usd?: number;
      duration_ms?: number;
      structured_output?: Record<string, unknown>;
    }
  | {
      type: "turn" | "tool_use" | "tool_result";
      content?: string;
      [key: string]: unknown;
    };

This makes the code more self-documenting and catches errors at compile time.

@claude
Copy link
Contributor

claude bot commented Nov 18, 2025

Code Review Summary

I've completed a comprehensive review of PR #682 using specialized review agents. Overall, this is a well-implemented feature with excellent test coverage, but there are some important issues to address before merging.

Critical Issues ⚠️

  1. Missing Environment Variable Configuration - The JSON_SCHEMA environment variable isn't passed to the base-action run step in action.yml, which will prevent structured outputs from working. See inline comment on run-claude.ts:398

  2. Error Handling Doesn't Properly Fail - Using core.setFailed() without throwing means the workflow may continue after errors. See inline comment on run-claude.ts:172

Security 🔒

  • Command Injection Risk - The json_schema input is passed through shell command construction. While the escaping is correct, recommend using environment variables or file-based approach for defense-in-depth. See inline comment on agent/index.ts:155

Performance ⚡

  • Unnecessary File I/O - Reading the execution file that was just written adds 10-50ms overhead. Can pass parsed data directly instead. See inline comment on run-claude.ts:164
  • String Concatenation in Loop - For large outputs, this causes O(n²) behavior. Recommend array accumulation.

Documentation 📚

Critical gaps in user-facing documentation:

  • The json_schema input is not documented in docs/usage.md or base-action/README.md
  • Main README doesn't mention this feature
  • action.yml outputs section doesn't explain dynamic outputs

The code comments and examples are excellent, but users won't discover this feature without documentation in the standard locations.

Test Coverage ✅

Strengths:

  • 341 lines of comprehensive unit tests
  • Good integration test coverage
  • Edge cases well-tested (zero, empty strings, decimals, etc.)

Gaps:

  • Unit tests don't actually invoke parseAndSetStructuredOutputs() - they only simulate behavior
  • Missing tests for special JavaScript values (undefined, NaN, Infinity)
  • No tests for circular reference handling in JSON.stringify

Code Quality 👍

Strengths:

  • Clean separation of concerns
  • Good error messages
  • Type conversions handled correctly
  • Security-conscious (value truncation, name sanitization)

Improvements needed:

  • Add stronger typing with discriminated unions for ExecutionMessage
  • Extract duplicated JSON schema handling code from both mode files
  • Add input validation for JSON schema format
  • Add output size limits (GitHub Actions has 1MB per output limit)

Recommendation

This feature is nearly ready to merge once the critical issues are addressed:

  1. Fix environment variable configuration
  2. Fix error handling to properly fail the action
  3. Add user-facing documentation

The security, performance, and test coverage improvements are important but can be addressed in follow-up PRs if needed.

Great work on the implementation and test coverage! 🎉

Add support for Agent SDK structured outputs feature.

New input: json_schema - accepts JSON schema for validated outputs
Auto-sets GitHub Action outputs for each field in the structured result

Docs: https://docs.claude.com/en/docs/agent-sdk/structured-outputs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@bogini bogini force-pushed the inigo/structured-outputs branch from 875e0ed to c102f7c Compare November 18, 2025 17:47
@bogini
Copy link
Contributor Author

bogini commented Nov 18, 2025

Closing to create fresh PR without automated review noise

@bogini bogini closed this Nov 18, 2025
"The schema may be invalid or Claude failed to call the StructuredOutput tool.",
);
core.setFailed(error.message);
throw error;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical Issue: Redundant error handling causes duplicate failures and loses stack traces

This function calls core.setFailed() and throws here, then catches and does it again at line 206-208. This creates:

  • Duplicate error messages in logs
  • Loss of original error stack traces
  • Confusion about which error is the "real" one

Recommendation: Remove the try-catch wrapper and let errors propagate naturally, handling them at the call site (line 403) instead:

// Remove the outer try-catch from parseAndSetStructuredOutputs
// Handle errors at call site:
if (process.env.JSON_SCHEMA) {
  try {
    await parseAndSetStructuredOutputs(EXECUTION_FILE);
  } catch (error) {
    const errorMsg = error instanceof Error ? error.message : String(error);
    core.setFailed(`Failed to parse structured outputs: ${errorMsg}`);
    throw error;
  }
}

This preserves stack traces and avoids calling core.setFailed() twice.

}

// Set GitHub Action output for each field
const entries = Object.entries(result.structured_output);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security: Potential prototype pollution vulnerability

Using Object.entries() without filtering inherited properties could iterate over __proto__, constructor, or prototype keys if present in a malicious Claude response. While @actions/core v1.10.1 mitigates classic injection, setting these as outputs could:

  • Pollute downstream workflow steps that parse outputs as JSON
  • Cause unexpected behavior in dynamic output references
  • Create security audit confusion

Recommendation: Add hasOwnProperty check:

for (const [key, value] of entries) {
  if (!Object.prototype.hasOwnProperty.call(result.structured_output, key)) {
    core.warning(`Skipping inherited property: "${key}"`);
    continue;
  }
  // ... rest of code
}

Reference: CWE-1321


for (const [key, value] of entries) {
const sanitizedKey = sanitizeOutputName(key);
if (!sanitizedKey) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security: Missing validation for reserved output names

A malicious schema could include fields named conclusion or execution_file, which would overwrite the action's critical outputs set at lines 398-399. This could cause:

  • Workflow failures due to missing/corrupted execution files
  • Incorrect status reporting (conclusion being overwritten)
  • Difficult-to-debug issues

Additionally, keys consisting entirely of special characters (e.g., "@@@") would sanitize to "___" which passes this check but creates ambiguous outputs.

Recommendation: Validate against reserved names:

const RESERVED_NAMES = ['conclusion', 'execution_file'];
if (RESERVED_NAMES.includes(sanitizedKey.toLowerCase())) {
  core.warning(`Skipping reserved output name: "${key}"`);
  continue;
}

// Also ensure at least one alphanumeric character
if (!/[a-zA-Z0-9]/.test(sanitizedKey)) {
  core.warning(`Skipping invalid output key after sanitization: "${key}"`);
  continue;
}

Comment on lines 398 to +403
core.setOutput("conclusion", "success");
core.setOutput("execution_file", EXECUTION_FILE);

// Parse and set structured outputs only if user provided json_schema
if (process.env.JSON_SCHEMA) {
await parseAndSetStructuredOutputs(EXECUTION_FILE);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical Issue: Race condition between file creation and structured output parsing

If the execution file write fails at line 391 (caught and warned at line 395), parseAndSetStructuredOutputs() will still be called with a potentially missing or corrupted file. This creates confusing error messages.

Recommendation: Only call parseAndSetStructuredOutputs if the file was successfully created:

let executionFileCreated = false;
try {
  await writeFile("output.txt", output);
  const { stdout: jsonOutput } = await execAsync("jq -s '.' output.txt", {
    maxBuffer: 10 * 1024 * 1024,
  });
  await writeFile(EXECUTION_FILE, jsonOutput);
  executionFileCreated = true;
  console.log(`Log saved to ${EXECUTION_FILE}`);
} catch (e) {
  core.warning(`Failed to process output for execution metrics: ${e}`);
}

core.setOutput("conclusion", "success");
if (executionFileCreated) {
  core.setOutput("execution_file", EXECUTION_FILE);
  
  if (process.env.JSON_SCHEMA) {
    await parseAndSetStructuredOutputs(EXECUTION_FILE);
  }
}

Comment on lines +153 to +163
const jsonSchema = process.env.JSON_SCHEMA || "";
if (jsonSchema) {
// Validate it's valid JSON
try {
JSON.parse(jsonSchema);
} catch (e) {
throw new Error(`Invalid JSON schema provided: ${e}`);
}
const escapedSchema = jsonSchema.replace(/'/g, "'\\''");
claudeArgs += ` --json-schema '${escapedSchema}'`;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Quality: Duplicated JSON schema validation logic

This exact validation and escaping code is duplicated in src/modes/tag/index.ts (lines 181-191). This violates DRY principle and creates maintenance burden.

Recommendation: Extract to a shared utility:

// src/modes/utils/json-schema.ts
export function buildJsonSchemaArgs(): string {
  const jsonSchema = process.env.JSON_SCHEMA || "";
  if (!jsonSchema) {
    return "";
  }

  try {
    JSON.parse(jsonSchema);
  } catch (e) {
    throw new Error(`Invalid JSON schema provided: ${e}`);
  }
  
  const escapedSchema = jsonSchema.replace(/'/g, "'\\''");
  return ` --json-schema '${escapedSchema}'`;
}

Then use claudeArgs += buildJsonSchemaArgs(); in both files.

@claude
Copy link
Contributor

claude bot commented Nov 18, 2025

Comprehensive Code Review: PR #682

I've completed a thorough review using specialized agents across 5 key areas. Here's a summary of the most noteworthy findings:

Critical Issues (Must Fix Before Merge)

1. Race Condition in File Processing

Location: base-action/src/run-claude.ts:398-403

  • If execution file write fails (line 391), parseAndSetStructuredOutputs() is still called with potentially missing file
  • Impact: Confusing error messages when file operations fail
  • Fix: Track file creation success and only call parsing function if successful

2. Redundant Error Handling

Location: base-action/src/run-claude.ts:163-210

  • Function calls core.setFailed() and throws, then catches and does it again
  • Impact: Duplicate error messages, lost stack traces
  • Fix: Remove outer try-catch, handle errors at call site (line 403)

3. Security: Prototype Pollution Risk

Location: base-action/src/run-claude.ts:184

  • Object.entries() without hasOwnProperty check could iterate over __proto__, etc.
  • Impact: Could pollute downstream workflow steps
  • Fix: Add hasOwnProperty filtering (see inline comment)
  • Reference: CWE-1321

4. Security: Missing Reserved Name Validation

Location: base-action/src/run-claude.ts:189

  • Malicious schema could include fields named conclusion or execution_file
  • Impact: Overwrites critical action outputs, causing workflow failures
  • Fix: Validate against reserved names list

5. Missing Environment Variable Wiring (BLOCKER)

Location: action.yml and base-action/action.yml

  • The json_schema input is NOT mapped to JSON_SCHEMA environment variable
  • Code checks process.env.JSON_SCHEMA but it's never set
  • Impact: Feature cannot work in production despite tests passing
  • Fix: Add JSON_SCHEMA: inputs.json_schema to env sections

Important Issues (Should Fix)

6. Code Duplication

Location: src/modes/agent/index.ts:153-163 and src/modes/tag/index.ts:181-191

  • Identical JSON schema validation logic duplicated
  • Fix: Extract to shared utility function

7. Missing Documentation

  • json_schema missing from base-action/README.md inputs table
  • No documentation of dynamic outputs in action.yml files
  • Missing from docs/usage.md
  • Error behavior not documented anywhere
  • Fix: Add comprehensive documentation (see detailed feedback from doc reviewer)

Test Coverage Gaps

8. Zero Error Path Testing

  • All 26 unit tests focus on happy paths
  • No tests for file read failures, malformed execution files, or missing structured_output
  • No mocking of @actions/core to verify outputs are actually set
  • Fix: Add error scenario tests, mock GitHub Actions API calls

Performance Assessment

No performance issues identified. Implementation is well-optimized:

  • Single file read with proper buffering
  • Linear scans appropriate for typical data sizes (<50 messages, <20 outputs)
  • Total overhead <50ms, negligible compared to Claude inference time

Security Summary

Overall Security Posture: Moderate - requires improvements

Positive:

  • Modern @actions/core v1.10.1 prevents classic command injection
  • Proper input sanitization with sanitizeOutputName()
  • No direct shell execution from outputs

Needs Fixing:

  • Prototype pollution protection (add hasOwnProperty checks)
  • Reserved name validation (prevent overwriting critical outputs)
  • Length limits on output names/values (prevent resource exhaustion)

Recommendations by Priority

Before Merge:

  1. Fix race condition in file processing (Issue Modify base action #1)
  2. Remove redundant error handling (Issue Modify action name #2)
  3. Add prototype pollution protection (Issue Claude code manual install link is incorrect #3)
  4. Add reserved name validation (Issue Can this be used with a Max subscription (without an API key)? #4)
  5. Wire up JSON_SCHEMA environment variable (Issue Support Max plan #5) - BLOCKER
  6. Extract duplicated code (Issue "Couldn't install GitHub App: gh: Not Found (HTTP 404)" #6)
  7. Add comprehensive documentation (Issue Error when running GH Action: The workflow is not valid.... A sequence was not expected #7)

Follow-up PR:
8. Add error path test coverage
9. Add length limits for outputs
10. Add integration test for failure scenarios

Positive Observations

  • Excellent test coverage for happy paths (26 unit tests, 5 integration tests)
  • Clean function separation and TypeScript types
  • Good use of @actions/core library features
  • Comprehensive integration tests with explicit prompts (fast, deterministic)
  • Proper security consciousness in sanitization approach

I've posted inline comments on the most critical code locations. Please address the blocking issues before merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant