Give Every Agent Node a Contract

TL;DR

  • A node is one bounded job with a defined input and a validated output. A node that returns free text is a node only a human can read.
  • The load-bearing claim is categorical, not a rate: a schema moves the failure from the consumer to the boundary, where it can be retried automatically instead of debugged by hand.
  • 20 single-node runs measured it: 9/10 vs. 10/10 consumable on the first try, and that gap is not statistically significant (p = 1.00). But 10/10 vs. 0/10 required a parse step that can throw. A separate, disclosed incident shows the trap: a schema with no way to say “I failed” burned a 5-retry cap on 9 of 10 runs for zero information.

📊 Result, up front. 20 runs, schema the only variable: 9/10 vs. 10/10 consumable first-try (p = 1.00, not significant) but 10/10 vs. 0/10 required a parse step. All 20 output files came out byte-identical either way.

You are here. Loop Part 2’s spec had one_step: "fix one batch of findings (batch = one file), then re-run the check." That sentence is already a node: a job, an input, an output. It has no contract, because its only consumer was a human reading the terminal. The moment the consumer is another node, that stops working. If you need the loop spec, reread Anatomy of a Loop; this part extends Part 1’s edge argument one level down, inside a single node.

A node with a job and an output that nothing constrains is fine right up until something other than a human reads it. Give a Claude Code node one task, ask for JSON in the prompt, and the model will usually produce it.

The problem shows up downstream, when a second node tries to parse that return and hits a string it can’t trust. An agent node contract fixes this at the boundary: an input type and an output type, enforced where the return crosses into the next step instead of only requested in prose. This tutorial takes one node from the Loop series, gives it that contract, runs it 20 times with and without a JSON Schema, and shows exactly what changes.

Prerequisites:

  • You’ve read Part 1 (From Loops to Graphs) and its edge argument.
  • You know your way around a JSON Schema: type, required, additionalProperties.
  • You’ve called agent() or an equivalent structured-output API at least once.

By the end you can state precisely what a schema buys a single node’s return, and why a schema that can’t say “I failed” is worse than no schema at all.

What is an agent node contract?

A node is one bounded job with a defined input and a validated output. A node that returns free text is a node only a human can read.

An agent node contract is exactly that: an input shape and an output shape, both fixed before the prompt is written, and both stated as a JSON Schema instead of a sentence the model might follow. The rung for this post is small on purpose, one file in and four typed fields out:

in: { file: string }
out: { file: string, fixed: integer, remaining: integer, exitCode: integer }

That’s the same lint-fix job from Loop Part 2: point the node at one file, let it run flake8, fix what’s reported, return the result. Nothing about the job changes here. No second node reading this one’s output yet. The only thing new is the contract: an input shape and an output shape, both written down before the prompt is.

That’s a small change with a real reason behind it. When the only consumer was a human at a terminal, a prose request for JSON worked fine, because a person reads past a stray sentence and finds the object inside it. A parser doesn’t skim. The moment the consumer is a script, or another node, that tolerance disappears, and the request has to become a contract instead of a suggestion.

What does a schema actually change?

The categorical claim comes first, before any rate number: a schema moves the failure from the consumer to the boundary. It can be retried automatically there, instead of debugged by hand later. That claim doesn’t depend on how many times you run the node. It’s the argument this section makes before a single number shows up; the number below illustrates it, and doesn’t prove it.

Compare what agent() hands back in each case:

No schemaSchema
What agent() hands the callera stringa validated object
Parse step in the consumerrequired, every timenone
Where a shape error surfacesin the consumer, at parse timeat the tool-call layer, before the caller sees it
Who retries a malformed returnyou, by handthe harness, automatically

None of that depends on sample size. It’s a type difference: a string that might parse versus an object that already has. The run below illustrates the difference. It doesn’t establish it.

What happened when we ran it 20 times?

20 runs of the same node, 10 with a schema and 10 without, split 9/10 versus 10/10 on the consumability test: whether a downstream step could use the return with no hand-editing. That gap is not statistically significant (Fisher’s exact test, two-sided p = 1.00), a caveat this section states here, in the body, not in a footnote.

The fixture was deliberately seeded. scripts/gen-agent-map.py is clean today, since Loop Part 2 already fixed it. So this run seeded it back to that post’s exact baseline: one unused import (F401), two missing blank lines before function defs (E302), three findings total, flake8 exit code 1. That’s a constructed fixture, built for this test, not a found one.

Both arms ran the exact same prompt, byte-identical: run flake8, fix what it reports, run it again, return the result as JSON with four keys (file, fixed, remaining, exitCode). Variant A asked for that shape in prose and returned the model’s raw text; variant B passed the same shape as a JSON Schema and got back a validated object. The schema was the only variable.

ArmConsumable first trySemantically correctMean tokens/runMean wall clockMean tool calls
A, no schema9/109/1031,99715.4s5.9
B, schema10/1010/1032,30117.0s7.1

All 20 nodes did the underlying work correctly. Every scratch copy ended at zero findings and exit code 0, and all 20 output files came out byte-identical. The schema changed what the node returned. How well it worked stayed the same.

The one failure a downstream step couldn’t use was run A10, in variant A:

All 3 findings fixed (removed unused `os` import, added second blank line before `count_posts` and `check_integrity`); final run is clean and the file still parses.
{"file": "/home/claude/graph-p2/run/A10/fixture-lint-node.py", "fixed": 3, "remaining": 0, "exitCode": 0}

JSON.parse throws on that string. The JSON inside it is perfect: right keys, right types, right values. The node did its job and added one helpful sentence on the way out, and that sentence is what broke the edge. Call it a good node with an unaddressed audience: it wrote for a human reader while the actual consumer was a parser.

10 runs of the no-schema arm produced exactly one way for prose-wrapped JSON to fail a consumer:

Failure modeObserved?
prose wrapped around the JSONyes, 1/10 (A10)
markdown code fence around the JSONno
renamed fieldno
number returned as a stringno
extra keys beyond the contractno
missing keyno
valid JSON, wrong valuesno

A post that listed four failure modes after observing one would be inventing evidence.

The 9/10-versus-10/10 gap is a single discordant observation (p = 1.00), and it’s also too small a sample to reliably catch a modest failure rate in the first place:

Runs per armP(at least one failure appears)
100.65
200.88
500.995
100~1.00

At 10 runs per arm, a true 10% no-schema failure rate would still produce a clean 10/10 about 35% of the time. There’s a caveat in the other direction too: variant B’s 10/10 is partly true by construction, since the harness retries against the schema until it’s satisfied. Report it as enforcement holding, not intelligence improving.

On this fixture the contract was close to free: +304 tokens (+0.9%), +1.6 seconds (+10%), and +1.2 tool calls per run over the no-schema arm, the cost of the extra structured-output call. Nothing retried. That’s the cheap case; the next section is the expensive one.

What happens when a contract can’t say “I failed”?

A schema that’s strict about the happy path can leave a node with no legal way to report the unhappy one, and this rung has exactly that trap built into it. Two mechanism notes first, then the incident that surfaced it.

additionalProperties: false is not required by the Claude Agent SDK validator, and the examples in its docs leave it out. It is a choice, and this run made it deliberately: once set, it forbids the model from returning any key outside the declared properties. Leave it out and a loose schema can leak, because the model can attach a field you never validate and a downstream step never reads. A related knob, agentType, swaps in a different tool surface when the default one is not the right fit. That is about defining an agent, which Claude Code Components Explained already covers; this post is about what an agent must return.

This part’s rung has exactly that trap in it, and the trace disclosed it by accident, during a run whose script hit a transient tool-permission fault. Every subagent’s Bash, Read, Glob, and Grep calls were blocked before execution. That run produced no data and was discarded; every number above comes from the clean re-run only. But both arms hit the identical blocker, and their two contracts behaved completely differently.

“BLOCKED: every tool call (Bash, Read, Grep, Glob) is rejected by the environment’s permission handler … I could not run flake8, read the file, or edit it, so no real before/remaining counts exist. Numbers above are not measurements…”

That’s A08, from variant A, unconstrained: 10 of 10 runs returned a clear prose error, mean 6.5 tool calls, 21.1 seconds, 27,450 tokens. Variant B fared far worse: 9 of 10 runs hard-failed on StructuredOutput retry cap (5) exceeded, mean 12.0 tool calls, 52.6 seconds, 31,534 tokens, and zero usable information. The schema had four required fields and additionalProperties: false, with no error field and no way to add one, so the agents refused to fabricate a clean result. Unable to accept a shape it hadn’t been told about, the harness retried five times and gave up.

The lesson here is precise: a contract must be able to express failure, or the node’s only honest options are to lie or to die. Both are worse than a field for the truth. The fix isn’t a looser schema; it’s a discriminated union, an ok: false branch carrying an error string beside the success shape:

// success-only (the trap): no legal way to report a block
{ "file": "...", "fixed": 0, "remaining": 0, "exitCode": 0 }
// discriminated union (the fix): a legal way to fail
{ "ok": true, "file": "...", "fixed": 0, "remaining": 0, "exitCode": 0 }
{ "ok": false, "error": "permission handler rejected every tool call" }

Try it now. Take the node you already run, write its rung, and paste the shape below into the schema your harness accepts:

{
"type": "object",
"properties": {
"...": "your node's real output fields go here, typed"
},
"required": ["..."],
"additionalProperties": false
}

Then add the failure branch before you ship it:

{ "ok": true, "...": "the success shape above" }
{ "ok": false, "error": "..." }

Pass that combined shape to your harness’s structured-output option instead of asking for JSON in the prompt. That’s the whole contract: one schema, one failure branch.

FAQ

Q: What is an agent node contract?

A: The in/out shape of a single bounded job: an input type and a validated output type, enforced at the tool-call layer instead of just requested in prose.

Q: Does a JSON Schema make the model smarter?

A: No. In this run, the schema arm’s 10/10 is partly a construction effect: the harness retries against the schema until it’s satisfied. That’s enforcement doing its job, a different claim than the model writing better JSON on its own.

Q: What happens when a schema can’t express failure?

A: The node either fabricates a passing result or exhausts its retry cap trying to satisfy a shape it can’t honestly fill. In this trace, that cost the schema arm a 5-retry cap burned on 9 of 10 runs for zero usable information.

Q: Do you need a schema for a one-off node a human reads?

A: Not necessarily. The contract earns its keep the moment anything other than a human is the consumer, which is every node after this one in the series.

Close

You now have a node whose return a machine can trust: an input shape, an output shape, and a validator standing at the boundary instead of a prose request. Where this leaks if you skip it: a schema with no failure branch is the one pitfall this trace surfaced firsthand, the incident above, and the fix costs one extra field, not a rewrite.

Take one step in a workflow you already run and write its input/output contract before you write its prompt.