AI

Calling Claude Code from a shell script like a typed function — I measured --json-schema

--json-schema is not a valid-JSON guarantee, it is a hidden tool call. Six runs later: twice as slow, keys going missing, and a silent zero exit code when it fails.

Bu yazının Türkçesi: Türkçe sürüm.

Using Claude Code as a chat window is the easy part. What I care about is calling it from inside a shell script like any other command — pipe input in, pipe output to jq, branch on the result.

The -p (print) mode covers most of that, and plenty of people know it. The flag next to it gets far less attention: --json-schema. You hand it a JSON Schema and Claude Code is on the hook for output that matches. The CLI reference puts it plainly: "validated JSON output matching a JSON Schema after the agent completes its workflow", print mode only.

That sounds like "no more broken JSON". I measured it. It isn't, and the reason is more interesting than the feature.

Version on my machine: 2.1.251. All runs on 27 August 2026 with --model sonnet.

The setup

I wrote six fake Turkish user reviews, the kind Zıpla actually gets: a freeze after an update, an ad that keeps showing after the no-ads purchase, battery drain, mangled Turkish characters in the score table. The task: turn each review into a record with id, category, severity, version and summary_en.

Two arms, three runs each. Arm A got no schema, just "respond with ONLY a JSON object of this shape, no prose, no markdown fences" appended to the prompt along with an example shape. Arm B dropped that sentence and handed the same structure over via --json-schema instead.

I piped the input in with cat reviews.txt | so file-read permissions stayed out of the picture. Both arms ran with --output-format json, and I pulled the numbers out of that envelope.

First result: the sales pitch is wrong

All three runs in arm A produced clean JSON that went straight through jq. No code fences, no "here is the JSON you asked for" preamble. Ask a current model for JSON with a decent prompt and you get JSON.

So selling --json-schema as a validity guarantee is the wrong framing. Here is what it costs:

MeasureA (no schema)B (--json-schema)
Turns12
Mean wall clock5.3 s11.0 s
Mean output tokens4041,011
Thinking tokens0326–567

Same job, twice the wall clock, two and a half times the output tokens. I left dollar figures out because prompt-cache state swung wildly between runs; tokens and seconds are the honest measures here.

Second result: the real difference, and not in the direction I expected

In arm A the version key was present in all six records every time. Reviews that never mentioned a version got an empty string. Same in all three runs.

In arm B I had deliberately left version out of the schema's required list. The consequence: in two of the three runs, the key is simply absent for reviews with no version. A script reaching for .version gets null.

So the schema did not make the output shape more stable than the plain prompt. It made it less stable. The contract in a JSON Schema is required, not properties. properties says "if this field shows up, here is its type." Every field outside required is a branch your consuming code must handle. I knew that in theory, but I did not expect two different shapes across three runs of the same command.

One odd side observation: both arms got an identical category list and still classified differently. The review saying "battery drained on 2.3.9, fixed now, thanks" came back as performance three times in arm A and praise three times in arm B — each arm internally consistent, the two disagreeing. Even when you supply the label set, the route you use to request the output can move the answer.

Why: this is a tool call, not constrained decoding

Here is the misconception worth killing. People assume --json-schema is a token-level grammar constraint. It is not.

I opened up the stream:

claude -p '...' --output-format stream-json --verbose --json-schema '...'

The tool list in the system/init event carries one extra entry: StructuredOutput. Further down the stream the model calls that tool, and a tool result comes back reading Structured output provided successfully. That is the second turn in the table, and where the extra seconds and thinking tokens come from.

It lines up with the docs. The structured outputs page says validation happens by re-prompting on mismatch, and that exceeding the retry limit produces an error instead of data. A supervised loop, not a guarantee. Once that clicks, every other oddity falls into place.

The model can decline to call it

I fed it a schema that cannot be satisfied: minLength: 5 and maxLength: 2 on the same field.

The model spotted the contradiction and never called the tool. The result envelope still said subtype: success and is_error: false — but there was no structured_output field at all, and result held a prose explanation instead.

The docs warn about exactly this: a result can come back success with no structured output, and you should treat that as a failure. I hit it before I read that line.

The trap that would actually have bitten me

I found this by accident. You can use --json-schema without --output-format json — every example in the docs pairs them, but it isn't required. On its own, the schema-conforming JSON lands directly on stdout: no envelope, no jq '.structured_output' step. Cleaner for scripting.

Right up until it fails. I reran the impossible schema without the envelope:

So a CI step written as claude -p ... --json-schema '...' | jq -r '.field' does not fail when the model declines the schema. jq chokes on prose, and the pipeline reports success. Finding that in production would have cost me a day.

Always run with the envelope and check for the field yourself:

out=$(cat input.txt | claude -p "..." --output-format json --json-schema "$(cat schema.json)")
echo "$out" | jq -e '.structured_output != null' >/dev/null || { echo "schema not satisfied" >&2; exit 1; }
echo "$out" | jq '.structured_output'

required can ask the model to invent data

One more test. Same reviews, but a schema making three fields required that the data simply does not contain: reporter_email, device, crash_count.

The output validated perfectly. Its contents: reviewer1@example.com, reviewer2@example.com, and so on — six invented addresses. crash_count came back filled with 0s and a 1, a number appearing nowhere in the input.

The lesson is blunt: required is an instruction to fill the field. With no data to fill it from, the model makes something up, and schema validation will not catch it, because the validator checks types, not truth. Leave every field that might not be available out of required. That is the concrete version of the docs' advice to match the schema to the task. Note also that the format keyword, as in "format": "email", is accepted as an annotation and not enforced.

Two more quick traps

An invalid schema kills the run before it starts:

Error: --json-schema is not a valid JSON Schema: data/type must be equal to one of the allowed values...

Exit code 1. That's the good case, since CI catches it.

The annoying one: if your schema carries "$schema": "https://json-schema.org/draft/2020-12/schema", it is rejected. The validator expects draft-07. Zod emits 2020-12 by default, so anyone pasting z.toJSONSchema(...) output straight in hits this wall. The docs give the fix: pass target: "draft-7" when converting. Pydantic's model_json_schema() omits the $schema key entirely, so that path sails through.

Would I use it

Yes, but in a narrow place.

Don't use it for a one-shot extraction with no tool use. I measured that: writing "JSON only" into the prompt did the same job in half the time and held its shape more consistently across runs. Don't pay double for nothing.

Do use it when the agent is genuinely working — reading files, running commands, then reporting back. In that shape the final message wants to be a narrative, and holding JSON together on prompt discipline alone is a losing game. The StructuredOutput tool exists precisely for that: let the agent wander as much as it needs, then force one typed artifact at the door.

Limits of this measurement: one task, six lines of input, three runs per arm, one model. I never tested --bare, because it wants an API key and I run on a subscription login. My numbers show a direction, not a law. But three findings here don't depend on sample size at all — the schema is a tool, the model may decline to call it, and without the JSON envelope that refusal reaches you as exit code 0.

Reproducing this takes about ten minutes. Had I not spent them, I'd have been hunting an empty jq result for weeks.

Advertise on this blog, or work with us

MCALAB is an independent studio. For sponsorship, cross-promotion or a partnership:

ads@mcalab.com.tr

Details: Advertise & partner. For user support, see the support page.