---
url: 'https://adk.nht.io/batteries/media/plans.md'
description: >-
  Three front-ends compiling to one MediaPlan, fixed-point round-tripping,
  compile-without-execute, and the error contract that lets models repair their
  own statements.
---

# Plans & Self-Repair

## LLM summary — Media plans + error contract

* The three front-ends (builder / pipe / ops) compile to one `MediaPlan`; `toPipe(plan)` renders canonical pipe text (fixed-point: parse(toPipe(p)) ≡ p), `toOps(plan)` is total. Pipe and ops forms of the same statement produce byte-identical plans.
* `mp.compile(q | ops)` validates to a plan WITHOUT executing — dry-run/caching/tooling; throws exactly what `query()` would.
* Errors: `E_MEDIA_PIPE_SYNTAX` (position + exemplar), `E_MEDIA_UNKNOWN_VERB` (did-you-mean incl. suffix-word matches, narrowed verb list), `E_MEDIA_UNKNOWN_ARG`, `E_MEDIA_BAD_ARG`, `E_MEDIA_MISSING_ARG`, `E_MEDIA_ENGINE_REQUIRED` (do-not-retry directive), `E_MEDIA_UNSUPPORTED_OP`. Every message ends with a corrective "Write it like:" exemplar.
* Engine narrowing happens at validation, never at parse — the same string parses identically in every deployment; only what's allowed differs. Suggestions never include verbs the deployment can't run.

Three ways to say the same thing, and exactly one thing they can say. A TypeScript developer writes a chainable builder. A language model writes a pipe string. A program assembling work writes a JSON ops array. All three compile to one neutral `MediaPlan` — the same plan, byte for byte, regardless of which door it came through — and from that point on nothing downstream knows or cares which front-end produced it. That convergence is not a convenience. It is the reason a 3B model and a senior engineer can drive the identical pipeline without the pipeline growing two code paths, two validators, two sets of bugs. One plan or it doesn't work.

And when a plan is wrong — wrong verb, wrong arg, a capability the deployment doesn't have — the failure is written for whoever has to fix it. Usually that's a model, mid-turn, with no human watching and no second chance to ask a clarifying question. An error a model can't act on without a human reading the stack trace is an error that doesn't get fixed.

## Three front-ends, one plan

The builder, the pipe parser, and the ops array all compile to the same neutral `MediaPlan`:

```typescript
const chain = mp(payload).select({ pages: [2, 3, 4, 5] }).convert('pdf')

chain.toPipe()  // 'select pages=2-5 | convert to=pdf'
chain.toOps()   // [{ verb: 'select', args: { pages: [2,3,4,5] } }, { verb: 'convert', args: { to: 'pdf' } }]
```

Round-tripping is **fixed-point**: `parsePipe(toPipe(plan))` produces an equal plan, and `toPipe` is idempotent. (It is *not* string-identity — `pages=2,3,4,5` renders canonically as `pages=2-5`, because there is one canonical form and the parser doesn't care which equivalent spelling you arrived with.) This is the property that lets the few-shot examples in the `media_query` tool description come straight from `toPipe` on real plans — they are generated from the parser, so they *cannot* drift from it. The day-one failure mode of every hand-written grammar doc — examples that quietly stopped parsing three refactors ago and nobody noticed — is structurally impossible here. If an example renders, it parses; if it parses, it runs.

## Compile without executing

`mp.compile(statement)` validates a pipe string or ops array to a plan without moving any bytes — useful for dry-runs, plan caching, and building tooling on top of the IR:

```typescript
const plan = mp.compile('select pages=1-3 | extract text')
// throws E_MEDIA_ENGINE_REQUIRED / E_MEDIA_UNKNOWN_VERB / … exactly as query() would
```

## Errors are written for self-repair

An error message that says "invalid input" is a message written for the person who threw it. Every error in this grammar is written for the party who has to fix it — usually a model, mid-turn, with no human watching. That means two layers, both ending in a corrective exemplar, because telling a model what's wrong without showing it what right looks like is half a message:

**Syntactic** — position-bearing, from the lexer/parser:

```text
pipe parse error: unexpected "-" — values containing dashes must be quoted at line 1, col 24.
Write it like: audio transcribe name="value-with-dashes"
```

**Semantic** — from the validator, against the verb table and *your configured engines*:

```text
unknown verb "redackt" at segment 2. did you mean "redact"? Available verbs: select, split, …
verb "convert" has no arg "format". did you mean "to"? Args: to. Write it like: convert to=pdf
arg "to" on "convert": "pdff" is not valid; valid values: pdf, html, txt, …
verb "convert" requires the "convert" engine, which is not configured in this deployment.
Do not retry this verb here. Available verbs: …
```

Did-you-mean matches suffix words too — a model that writes `resize width=256` (dropping the namespace) is pointed at `image resize`. Suggestions never include verbs your deployment can't run, because suggesting a verb that will immediately fail is not a suggestion, it's a prank. And engine narrowing happens at **validation, never at parse**: the same statement parses identically everywhere; only what's allowed differs. A statement is never "syntactically wrong here but fine over there" — that distinction belongs to the deployment, and the error says so in so many words.

::: info Field note: your prompt's brackets end up in the model's statement
The first live-model runs of the forge surfaced a trap we didn't design for: a prompt that says *"replace it with \[REDACTED]"* gets echoed into the statement as `replace=[REDACTED]` — brackets and all — which is an unquoted-special-characters lexer error. The model isn't being careless; it's being faithful to your prompt. The error's "write it like: `name="value"`" exemplar usually triggers an in-turn repair, and watching that repair loop actually work was the error contract earning its keep — but *usually* is not *always*, and a retry costs a round-trip. The cheaper fix is upstream: when your system prompt names a replacement token, name the bare word, not the bracketed one. Your prompt is part of the grammar's input surface whether you meant it to be or not.
:::

## Read next

* [Text, Data & Patches](./text-ops) — append, `data.*`, both `apply_patch` dialects, and redact per format.
* [The Capability Model](./engines) — where validated plans actually execute.
