claude-code - 💡(How to fix) Fix Field notes: StructuredOutput as a control-channel (a multi-agent workflow that died) + Agent-SDK sharp edges

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

Error Message

to make the StructuredOutput call (`API Error: the socket connection was closed

  • The error message conflated two very different failures. "Completed without

Root Cause

From a homelab user who builds multi-agent systems on local hardware. No live credentials, addresses, or private data appear below — everything is described at the pattern level. The three lessons are at the end; the story is how I got to them, because the story is where they're actually load-bearing.

Fix Action

Fix / Workaround

TL;DR for maintainers — the directly actionable part is the last two sections. A multi-agent workflow died because the StructuredOutput schema inlined full file contents into a structured field: the single running agent's context saturated and the connection dropped as it tried to call StructuredOutput, which surfaced as subagent completed without calling StructuredOutput (after 2 in-conversation nudges) — a message that points at the wrong cause. Fix: agents write artifacts to disk and return only metadata; modifications come back as patch notes. Plus five smaller Agent-SDK sharp edges. Everything before that is how the build came to exist, and where identity- and guardrails-as-infrastructure actually paid off.

  1. Agents write artifacts to disk and return only metadata. The schema's files field went from {path, host, kind, content} to {draft_path, target_path, host, kind} — the content string removed entirely. The structured channel now carries a manifest; the orchestrator reads bodies from disk when it needs them.
  2. Modifications come back as patch notes, not full rewrites. When several agents touch the same file, returning whole rewrites is a last-writer-wins collision. A scoped .patch.md (anchor + snippet + rationale) per target makes concurrent edits composable and reviewable.

The generalisable guidance: structured output is a control channel, not a data bus. For any fan-out that produces files, the default pattern wants to be "persist to disk, return references + metadata," and patch-style returns when targets overlap. The failure is easy to hit, and the fix is a small reframe — cheap to document, or even to nudge in the tooling.

RAW_BUFFERClick to expand / collapse

How a shared Mac became a watched DMZ — field notes for the Claude / Agent-SDK team

From a homelab user who builds multi-agent systems on local hardware. No live credentials, addresses, or private data appear below — everything is described at the pattern level. The three lessons are at the end; the story is how I got to them, because the story is where they're actually load-bearing.

TL;DR for maintainers — the directly actionable part is the last two sections. A multi-agent workflow died because the StructuredOutput schema inlined full file contents into a structured field: the single running agent's context saturated and the connection dropped as it tried to call StructuredOutput, which surfaced as subagent completed without calling StructuredOutput (after 2 in-conversation nudges) — a message that points at the wrong cause. Fix: agents write artifacts to disk and return only metadata; modifications come back as patch notes. Plus five smaller Agent-SDK sharp edges. Everything before that is how the build came to exist, and where identity- and guardrails-as-infrastructure actually paid off.


Where it started

I run a small "council" of local AI agents on a Mac mini that I share with someone who also has root. A few agents reason offline; one — the researcher — reaches the internet; one orchestrator (a single capped cloud call a day) synthesises the result. The threat model was mundane and real: a shared box, an agent that phones out, and the uncomfortable fact that I had zero visibility into what any of them actually sent. They went straight out through the home router and onto the internet, and nothing recorded where they connected, what TLS stacks they used, or how much they moved.

I didn't want to block the agents and look away. I wanted the opposite — to let them work, and to always be able to answer "what did this one just do on the wire?" That goal — observability over restriction — drove everything below.


Act I — giving each agent a body on the network

The first problem: how do you tell two agents' traffic apart when they run as the same Unix user on the same host? (They do — there's no per-agent OS account.) You can't filter by user. You have to give each agent its own address.

The flat home LAN sits behind a single hardware bridge, and segmenting it with VLANs is the one change that can take the whole network down. So instead of touching L2, I laid a WireGuard overlay on top of it: a tunnel from the Mac into an OPNsense firewall VM, carrying a small private subnet. Each egress agent gets its own /32 address inside that tunnel — the researcher at …3, a fetcher at …4. Zero L2 change; the tunnel just rides the existing ethernet.

Then the first real surprise. The plan was a macOS pf rule — pass out … route-to matching the agent's source — to push agent traffic into the tunnel. The anchor loaded, the grammar was right, and it matched zero packets: Evaluations: 235, Packets: 0. The cause is a macOS-specific ordering fact: the kernel route lookup happens before pf. On a recent macOS, a packet from a tunnel source address with no kernel route to its destination is rejected before pf ever evaluates the route-to rule. pf was a dead end; I removed the anchor and left pf pristine.

What worked was lower-level and, in hindsight, more honest: an interface-scoped default route (route add -inet -ifscope utunN default <gw>) that carries only sockets explicitly bound to the tunnel's addresses, leaving the machine's real default route untouched — paired with per-agent source-address binding (a small shim that rebinds outbound TCP connect() to the agent's tunnel address when an env flag is set, and is off by default). The clean test for "is it in the tunnel or leaking?" turned out to be a single number: a connection timeout inside the tunnel (curl exit 28) means contained; a no-route (exit 7) means it leaked to the real interface. Two exit codes, one bit of truth.

One more beat, and it's the one that taught me the most. An adversarial review pass (a second agent whose only job was to try to break the first one's conclusions) found that assigning the WireGuard interface had silently auto-added an outbound-NAT rule — stored as a macro, so a literal grep for the subnet across the ruleset found nothing. The result was a double-NAT. My first instinct was "bug, fix it." But the right call was to leave it, once I saw why it didn't matter: the IDS captures traffic before any NAT. "NAT exactly once" had only ever been a proxy for attribution — and attribution was already guaranteed by the capture point, not by the NAT topology. The finding dropped from HIGH to cosmetic.

Lesson 1 — agent identity is an infra fact, not a framework setting. The thing that makes two agents distinguishable isn't a field in a config object the runtime checks; it's an address the network assigns, a route the kernel honours, a key the OS forced-commands. A framework-level scope is a convention the model is asked to respect. An OS/network-level scope is a fact the model collides with. (Honest caveat: in my system the manifest that "looks like" the identity source still has zero consumers today — real enforcement lives in six hardcoded places. The identity layer is real but fragmented; unifying it onto one file the enforcers read is the actual work. I'm not claiming a clean system — I'm claiming the right altitude for the primitive.)


Act II — watching without being seen

With each agent on its own address, the watching is cheap. An IDS (Suricata, in passive pcap mode) sits on the tunnel interface and records, pre-NAT, the metadata of every connection: TLS SNI, the JA3/JA3S fingerprints, DNS queries, HTTP hosts, reconstructed file hashes. Because capture is pre-NAT, every record keeps the agent's real per-agent source address — even though the packet is double-NATed before it ever leaves the house.

The "soft, untraceable" part — untraceable from the agent's side — is two properties working together. First, the observer is a different machine from the one the agents run on. Second, the data moves by a strict one-way pull: a second "verdict" host reaches the firewall over an SSH key whose forced-command can invoke exactly one script (emit the newest digest, exit) — restrict (no PTY, no forwarding), locked to the verdict host's address, and parked in a key file the appliance's own UI won't clobber. I verified the denials adversarially: an arbitrary command over that key is silently ignored, a PTY request refused, SCP rejected. The observed cannot reach the observer. The agent has no path to the capture point, can't see the logs, can't edit them, can't even tell they exist.

The pipeline is boring on purpose: every 15 minutes the firewall slices the last window of IDS events into a structured digest (top talkers, new destinations, DNS domains, TLS SNI+JA3, HTTP hosts, file hashes, alerts with their MITRE technique IDs); the verdict host pulls it read-only and runs it through a small engine that emits a MITRE-tagged verdict per window into an append-only log. A sliding window over the last five digests catches patterns that span windows — a beacon that looks quiet in any single 15 minutes shows up as a repeated talker across five.

What you can actually do with the logs

  • Attribute by agent, with no UID tracking. A new destination or a new SNI is tied to which agent role made it, because the source address is per-agent.
  • Catch a swapped or hijacked agent by its TLS fingerprint. Baseline each agent's JA3 hash. A changed JA3 from the same agent address means a different TLS stack is making the calls than the one that should be — a strong "this is not the same software anymore" signal. JA3S and the cert fields support pinning-style checks on the server side too.
  • DNS and file-hash hits. Unexpected domains per agent; reconstructed file-hashes cross-referenced against threat-intel.
  • A real triage lesson, learned from a false positive. An early heuristic matched extern=\d and so fired "Exfiltration (TA0010)" on idle windows where the value was extern=0 — every quiet tick paged. The fix was to make idle windows emit a literal "no egress in window" token. The durable lesson: the tactic tag and the severity are a pair — a non-empty tactic at severity=0 must not page. Tag tells you what kind; severity tells you whether to care.

Act III — closing the loop with "our side"

The egress logs are only half the picture. The other half is what the agents were allowed to do, recorded on the agent side: an append-only audit log of every gate decision, the orchestrator's run start/end times, and the manifest declaring each agent's permitted calls. The interesting part is the join:

Network side (IDS)Agent sideWhat the join proves
source address of a flowthe agent's declared per-agent egress addressthe flow belongs to this agent identity
IDS event timestampthe orchestrator run's start/end windowthe flow belongs to this specific run
TLS SNI (e.g. the provider's API host)the agent's allowed_calls in the manifestan SNI outside allowed_calls is a call the gate should have blocked — a contradiction between permitted and observed

That last row is the whole point: you can now detect the difference between what an agent was authorised to do and what it actually did on the wire — and an agent reaching a destination it has no visa for is an alarm, not a log line. (Honest gaps: the join leans on the two clocks being NTP-synced — a silent precondition I should make explicit — and one of my gates currently logs nothing, so that side of the join is reconstructed from timestamps alone until I wire it.)

Watching the agents themselves: mtime and prompt-hash

The same "make it a fact, not a request" idea applies to the agents' own files:

  • Prompt-hash drift. At startup, the harness takes a SHA-256 of each agent's prompt file and compares it to a stored baseline. A mismatch aborts the whole run before any agent makes a single call — unless I explicitly approve the change. This is what catches a silently edited, injected, or swapped agent prompt: the council never even loads a prompt that doesn't hash to what I last approved.
  • Rate-limit as a file's mtime. The capped cloud call's once-a-day budget isn't a counter the model is asked to honour — it's the mtime of a sentinel file. The harness touches it after a successful call and stats it before the next. Nothing the model can say moves that timestamp; only the harness does.
  • Killswitch as a sentinel file. A file's presence aborts everything. Again: a filesystem fact, not an instruction.

Lesson 2 — guardrails should be technical barriers, not asked-for conventions. Every safety property above is enforced by a mechanism — a hash compare, a file mtime, a forced-command, a pre-NAT capture point — never by an instruction in a prompt. The test is simple: if a sufficiently clever prompt can talk its way past your guardrail, it was never a guardrail — it was a request. The design work is moving each "the model should…" into a "the runtime won't let it…".


The lesson that's really for you — structured output is a control channel, not a data bus

I built the components with a multi-agent workflow that fanned out subagents to draft each piece. The first run died, and the failure is worth your time because the symptom hid the cause.

What happened: the keystone subagent (the fan-out hadn't even started — only this one was running) did six minutes of genuine work — 25 tool calls: reading files, writing draft artifacts, running a test harness. Its output schema asked it to return the full file contents of everything it had drafted, inlined into a content string field of the structured output. By the time it had done all that work and was holding every drafted file's body to hand back through the structured channel, its context was saturated — and the connection dropped exactly as it tried to make the StructuredOutput call (API Error: the socket connection was closed unexpectedly). The harness surfaced this as:

subagent completed without calling StructuredOutput (after 2 in-conversation nudges)

That message sent me looking for a model that refused to call the tool. The real problem was upstream: I had designed the structured return to carry file-sized payloads, which guaranteed a bloated context and a fragile final call.

The fix, which made the second run clean (15 subagents, every component drafted, nothing dropped):

  1. Agents write artifacts to disk and return only metadata. The schema's files field went from {path, host, kind, content} to {draft_path, target_path, host, kind} — the content string removed entirely. The structured channel now carries a manifest; the orchestrator reads bodies from disk when it needs them.
  2. Modifications come back as patch notes, not full rewrites. When several agents touch the same file, returning whole rewrites is a last-writer-wins collision. A scoped .patch.md (anchor + snippet + rationale) per target makes concurrent edits composable and reviewable.

I had to write the rule to myself, as a comment at the top of the second script: "the previous run died inlining file contents into StructuredOutput — do NOT repeat."

The generalisable guidance: structured output is a control channel, not a data bus. For any fan-out that produces files, the default pattern wants to be "persist to disk, return references + metadata," and patch-style returns when targets overlap. The failure is easy to hit, and the fix is a small reframe — cheap to document, or even to nudge in the tooling.

A few smaller sharp edges from the same build, in case they're useful:

  • The error message conflated two very different failures. "Completed without calling StructuredOutput (after 2 nudges)" reads identically whether the model ignored the nudges or the socket closed mid-call. Distinguishing them in the message would have saved me the transcript dig.
  • The failure notice reported subagent_tokens: 0 even though the agent clearly burned six minutes and 25 tool calls. Token accounting that only populates on a successful structured return makes post-mortems on what it cost before it failed hard.
  • No automated "your context is getting large" signal. I diagnosed this only by reading the raw subagent transcript by hand. A hint like "context grew to N tokens before the drop — consider narrower agent scope" would have pointed straight at it.
  • resumeFromRunId caching is great and underdocumented. That completed agents return cached results on a re-run drastically lowers the cost of iterating on a failed workflow — it deserves to be louder in the docs.
  • The rm -rf sandbox guard silently accumulates /tmp debris. A subagent's test-then-cleanup pattern hits a denied rm -rf and moves on; the debris piles up across calls. Correct behaviour, surprising in practice — worth a note.

The public pattern repo it came from is a guide to a safe, observable local-AI agent environment with network-egress monitoring — same content, cred-free: github.com/zzallirog/safe-agent-env

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

claude-code - 💡(How to fix) Fix Field notes: StructuredOutput as a control-channel (a multi-agent workflow that died) + Agent-SDK sharp edges