Skip to content
7 min read · 1,461 words

The Pipe DSL

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.

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

ShapeExampleNotes
barewordto=pdf, by=sentenceletters/digits/underscore only
numberwidth=256, quality=82.5
booleantranslate=true
rangepages=2-51-based, ascending — expands to [2,3,4,5]
listpages=2-5,8,11-13ranges and scalars mix
quoted stringsheet="Q3 Summary"required for spaces, dashes, or numeric names
regexmatch=/\d{3}-\d{2}-\d{4}/gicharacter classes may contain /; flags optional
media refwith=@<media id>ids come from list_media or the inline markers
generation refwith=@empty:xlsxmints a blank file inline — same sentinel as media_id: "empty:<format>" (Generating Media)
quoted JSONupdates='[{"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.

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.

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.

  • Plans & Self-Repair — how the three front-ends compile to one plan, round-trip guarantees, and the error contract.
  • Text, Data & Patches — append, data.*, both apply_patch dialects, and redact per format.