TLDR: claude -p --output-format json does NOT hand you the model's JSON. It wraps it in an envelope. The model's text lives inside a .result field — and that text might have a prose preamble or markdown fences around it. Parse defensively, in one place, for every model.

The Setup

Apollo (my personal AI agent, built on Claude Code) runs a two-model scanner pipeline.

Sonnet (my fast, cheaper model) scans Gmail, Slack, and Calendar every hour — collecting and classifying anything that needs my attention.

Opus (my heavyweight judge) runs every other hour — reviewing everything Sonnet dropped as "noise," resolving anything Sonnet wasn't confident about.

Two models. Two separate output paths. And for a while, two separate JSON parsers.

That was the mistake.

The Wall

Opus started silently failing on certain outputs.

The judge would return something like:

Here's the classification JSON you requested:

{"classification": "urgent", "reason": "..."}

Prose preamble. Then the JSON.

My original parser expected clean JSON and just… died. It couldn't handle prose preambles. The whole scan batch would fail, not just the one item. Gone.

And I had no good way to catch it because the failure mode was quiet — I was calling claude -p --output-format json and trusting that I got structured output back.

I did not.

What I Got Wrong

Here's the thing nobody warns you about: --output-format json wraps the model's response in an envelope.

The model's actual text comes back as a string inside a .result field. You get something like:

{"result": "Here's the JSON you asked for:\n\n{\"classification\": \"urgent\"}"}

So you need to:

  1. Extract .result from the outer envelope
  2. Parse the JSON out of that string (strip preamble, strip fences — double parse)
  3. Validity-gate the result before trusting it

That trio is the part that bites. Every time.

The Fix That Worked

I refactored both paths into a single shared parse_llm_json() function.

Every LLM output — Sonnet AND Opus — goes through it. One place. The logic:

  • Pull .result out of the outer envelope
  • Strip any markdown fences (```json ```)
  • Strip prose preamble before the first { or [
  • Parse, validate, return

I also fixed two other things that were making crashes worse:

  • Pydantic extra="forbid" was rejecting valid Opus responses that included a stray field. Changed it to extra="ignore" — Opus adds fields sometimes, and that's fine.
  • Per-item validation — ScanResult now validates each ScanItem individually. One malformed item (bad enum, missing field) no longer kills the entire batch.

Backed it with 18 tests for json_parser — fences, preambles, missing fields, bad enums, the works.

Why This Matters to Me

Before this refactor, I had implicit trust in the output format flag. I thought "structured output" meant I was done worrying about parsing.

I wasn't.

The real lesson: treat every LLM output like you'd treat user input. Validate at the boundary, in one place, for every model that touches your system. Not per-model. Not per-path. One parser, shared.

If you're calling claude -p — or any LLM subprocess — from a daemon or script, assume prose. Assume fences. Assume one bad item in a list. Defensive parsing isn't extra work. It's the whole job.

P.S. The scanner's been stable since Cycle 8. Twenty items left in the backlog, all features. Not bad for something that was silently dropping Opus output two weeks ago.