Field notes: StructuredOutput as a control-channel (a multi-agent workflow that died) + Agent-SDK sharp edges

Resolved 💬 1 comment Opened May 29, 2026 by zzallirog Closed Jul 2, 2026

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 side | What the join proves |
|---|---|---|
| source address of a flow | the agent's declared per-agent egress address | the flow belongs to this agent identity |
| IDS event timestamp | the orchestrator run's start/end window | the flow belongs to this specific run |
| TLS SNI (e.g. the provider's API host) | the agent's allowed_calls in the manifest | an 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.

  1. 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*

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗