---
url: 'https://adk.nht.io/batteries/media/dsl.md'
description: >-
  The grammar models write: why a pipe DSL at all, named-arg pipe statements,
  separator-insensitive verbs, value shapes, and quoted-JSON payloads.
---

# The Pipe DSL

## LLM summary — Media pipe DSL

* Grammar: `pipeline := segment ('|' segment)*`; `segment := verb arg*`; `arg := name=value` (named ONLY — no positional args). Verbs are one or two words and fold separators: `extract_text` ≡ `extract text` ≡ `extract.text`; `sheet.update_cells` ≡ `sheet update_cells` ≡ `sheet update cells`.
* Values: barewords (`pdf`, `sentence`), numbers, `true`/`false`, ranges `2-5` (1-based, ascending), comma lists `2-5,8,11-13`, quoted strings `"with spaces"` / `'single'`, regex literals `/\d{3}/flags` (character classes may contain `/`), `@id` media refs (incl. the generation sentinel `@empty:<format>` — mints a blank file inline, e.g. `merge with=@empty:xlsx`), quoted-JSON structured payloads `updates='[{"address":"B2","value":3}]'`.
* Rules: ALL indices are 1-based. Bare number = index, quoted string = name/title (`sheet=3` vs `sheet="2026"`). Values containing dashes must be quoted (`lang="en-US"`). `#` comments are tolerated. Whitespace around `=` and multi-line pipes are fine.
* Inside JSON tool args, backslashes double: a regex is written `"match=/\\\\d{3}/"`.
* Engine narrowing happens at validation, never at parse — the same string parses identically in every deployment. Plans, round-tripping, and the error contract: see the Plans & Self-Repair page. Text/data verb semantics (append, data.\*, apply\_patch dialects): see Text, Data & Patches.

You will never write a pipe statement. Your model will. So why does this page exist?

Because the grammar is the contract between your deployment and the model, and you need to know what that contract guarantees. Three things, and they're the reason this page is in the docs instead of a source comment:

1. **You control what the model can ask for.** The advertised verbs narrow to the engines you configured. No convert engine? `convert` isn't in the grammar the model sees, and a statement that uses it anyway gets a *do not retry this here* answer. The grammar is your deployment's capability surface, rendered as text — and you are the one who decides what's on it.
2. **You will read these statements in audit logs.** Every pipe statement is self-documenting: `select pages=2-5 | redact match=/\d{3}-\d{2}-\d{4}/ replace="[SSN]" | convert to=pdf`. When you're investigating what your agent did to a file three weeks ago, that line is what you'll see. Named args, no positional mystery, no "what did parameter 3 mean in this context."
3. **The error contract is your safety net.** When the model writes a statement that doesn't parse or asks for a verb your deployment doesn't run, the error tells it exactly what went wrong and how to fix it — and the turn ends with a readable failure rather than a retry loop. You don't write the repair; the grammar teaches it. But you should know it exists, because "the model retried a broken statement 47 times" is an incident you don't want to explain.

The chainable builder is lovely implementor DX, but the reason this battery is shaped the way it is — one plan, one grammar, one validator — is so that a language model of any size can perform media work without us betting on training priors it may not have. Everything on this page serves that goal, and anything that didn't serve it got cut, including several features we liked.

## Why a pipe DSL, of all things?

Media pipelines have no standard. None. SQL gave data its lingua franca and knex made it chainable, but there is no canonical way to say "take pages 2–5, redact the SSNs, convert to PDF." GStreamer invented a graph syntax, ffmpeg invented filtergraphs, ImageMagick invented flags; none of them parse to a reusable AST in JavaScript (we looked — there is no library, only string builders), and none of them is something you can assume a language model has reliably internalized.

::: info Field note: the chaos is in the dependencies
Look at what this battery actually integrates: pdf-lib speaks pages and 0-based indices, ExcelJS speaks worksheets and A1 addresses, JSZip speaks archive entries, mammoth speaks one-way HTML conversion, jimp and sharp disagree about resize options, tesseract speaks languages and segmentation modes, Whisper speaks chunk timestamps. Seven-plus vocabularies for what a user experiences as one job. The obvious integration — one narrow tool per library capability — just forwards that inconsistency to the model: dozens of tools, each argument shape a fresh negotiation. The lesson wasn't "write better tool descriptions." It was that the chaos is structural, and it has to be absorbed *below* the tool surface. That's why this battery is a grammar with engines behind it, not a catalog.
:::

That last point is the design constraint that matters: **LLMs don't share one mental model of this chaos.** Training corpora differ across models and sizes. What a frontier model "knows" about ffmpeg, a 3B quant fumbles. Handing the model dozens of bespoke tools — or a vendor-specific syntax — is betting your product on training priors you cannot inspect, in a model you may not have picked yet. We decline the bet.

So we didn't borrow a media tool's grammar. We borrowed the most universal transformation idiom in existence: the shell pipe with `key=value` arguments. Every model, at every size, has seen `a | b | c` more times than any media syntax ever written:

```text
select pages=2-5 | redact match=/\d{3}-\d{2}-\d{4}/ | convert to=pdf
extract text | chunk by=sentence size=512
image resize width=256 | image format to=jpeg quality=82
audio transcribe lang=en out=srt
```

The advertised verbs narrow to the engines you configured. No convert engine? `convert` isn't in the grammar the model sees, and a statement that uses it anyway gets a *do not retry this here* answer rather than a loop of failures.

## The grammar

```text
pipeline := segment ('|' segment)*
segment  := verb arg*
arg      := name=value
```

That's the whole shape. Three lines. If a media grammar needs more than three lines of EBNF, it has started designing for its author instead of its reader.

Named arguments only — there are no positional arguments, and there never will be. Positional args save the model four characters and cost it an ambiguity class: an identifier followed by `=` is an arg; otherwise it's the verb's second word, no lookahead table required. Every statement is self-documenting, which matters when the next reader of the statement is a human in an audit log.

```text
select pages=2-5 | redact match=/\d{3}-\d{2}-\d{4}/ replace="[SSN]" | convert to=pdf
extract text ocr=auto | chunk by=sentence size=512 overlap=64
sheet update_cells sheet="Q3 Summary" updates='[{"address":"B2","value":42}]'
merge with=@01906c2e-aa01-7000-8000-000000000001
image resize width=256 height=256 fit=inside | image format to=jpeg quality=82
audio transcribe lang=en out=srt translate=true
```

### Verbs fold separators

`extract_text`, `extract text`, and `extract.text` all parse to the same verb. So do `sheet.update_cells`, `sheet update_cells`, and `sheet update cells`. Models mix separator conventions constantly — that isn't a defect to correct, it's a fact to design around. Punishing a separator slip with an unknown-verb error costs a full retry round-trip to teach the model something it will forget by the next turn. So the parser simply doesn't care. Canonical output (`toPipe`) always renders space-separated words; the model can write whatever it was going to write anyway.

### Values

| Shape | Example | Notes |
| --- | --- | --- |
| bareword | `to=pdf`, `by=sentence` | letters/digits/underscore only |
| number | `width=256`, `quality=82.5` | |
| boolean | `translate=true` | |
| range | `pages=2-5` | 1-based, ascending — expands to `[2,3,4,5]` |
| list | `pages=2-5,8,11-13` | ranges and scalars mix |
| quoted string | `sheet="Q3 Summary"` | required for spaces, dashes, or numeric names |
| regex | `match=/\d{3}-\d{2}-\d{4}/gi` | character classes may contain `/`; flags optional |
| media ref | `with=@<media id>` | ids come from `list_media` or the inline markers |
| generation ref | `with=@empty:xlsx` | mints a blank file inline — same sentinel as `media_id: "empty:<format>"` ([Generating Media](./generating)) |
| quoted JSON | `updates='[{"row":2,"col":1,"value":"x"}]'` | structured payloads ride inside one quoted value |

Three rules carry most of the weight. They are not negotiable, and there are no per-verb exceptions, because a rule with exceptions is two rules and the model has to learn both:

1. **Indices are 1-based, everywhere.** Pages, rows, columns, slides, insert positions. Humans say "page 2" and mean the second page; the grammar agrees with humans, not with C.
2. **Bare number = index, quoted string = name.** `sheet=3` targets the third worksheet; `sheet="2026"` targets the worksheet *named* 2026. Yes, a worksheet named "2026" is a trap someone's spreadsheet will spring — which is why the missing-index error checks whether a sheet of that name exists and tells you to quote it.
3. **Quote anything with a dash.** `lang=en-US` is a lexer error with advice; `lang="en-US"` works. Bare `2-5` is a range — that's the collision the rule exists to avoid, and we'd rather make you quote a locale code than make `pages=2-5` ambiguous.

::: warning Writing regexes inside JSON tool arguments
When a model emits a pipe statement as a JSON string, backslashes must be doubled: the statement `redact match=/\d{3}/` is written `"redact match=/\\d{3}/"` in the tool-call JSON. The generated `media_query` examples show the escaped form for exactly this reason. Prefer literal strings (`match="123-45-6789"`) when the target is literal — no escaping hell at all.
:::

### Structured payloads are quoted JSON

Sheet rows, cell updates, slide table data — anything genuinely nested rides inside a single quoted JSON value:

```text
sheet add_rows rows='[["Widget",42],["Gadget",17]]'
slides update_table slide=2 updates='[{"row":1,"col":2,"value":"Q3"}]'
split ranges='[[1,3],[5,7]]'
```

A pipe statement is always complete, the JSON inside the quotes is plain JSON (models are *good* at JSON), and the validator parses and checks it with the same error quality as everything else. One grammar. No moods.

::: info Field note: the bimodal trap
An earlier draft of this grammar had the model *switch modalities* — pipe strings for simple work, a separate structured-JSON mode for anything nested. Adversarial review killed it: a fork in the road on every single call is a fork the model will eventually drive off, and the failure mode is nasty — the model retries a failing pipe statement in pipe form forever, never realizing the answer was "switch modes." Quoted-JSON values mean there is no second mode to fail to find. The statement the model already knows how to write is always sufficient.
:::

## Read next

* [Plans & Self-Repair](./plans) — how the three front-ends compile to one plan, round-trip guarantees, and the error contract.
* [Text, Data & Patches](./text-ops) — append, `data.*`, both `apply_patch` dialects, and redact per format.
