Agent DSLs and the Plan AST
Give the agent a small language with a grammar, and its plans become trees you can print, diff, refuse, rewrite and replay. `SEARCH(...) |> FILTER(...) |> SUMMARIZE()` as an AST before any tool runs is worth more than the same three calls made one at a time, and the reasons are the ordinary reasons an IR exists.
Why should an agent produce a plan in a small language instead of just calling tools one at a time?
A plan AST: an ordered tree of step nodes, each carrying a tool name, an argument map, references to earlier steps' results, and a span back into the text the model produced. It is the agent-system analogue of [[what-is-an-ir]] — a representation that exists so that something other than the executor can be written against it, and every one of the things this lesson claims you can do with a plan follows from having that representation rather than from any property of the model.
A plan may be rewritten — steps merged, reordered, deduplicated, hoisted — only if the rewrite preserves the plan's observable effects: the same tools called with the same arguments, in an order that produces the same results, and the same set of side effects on the world. That requires per-tool declarations the plan alone cannot supply: whether the tool is read-only, whether it is deterministic for a given argument tuple, and whether it is idempotent. Without those declarations no rewrite is legal, which is the plan-level statement of [[optimization-legality]]: semantics decide, not cleverness.
Key points
- A plan DSL is a real language: it deserves a written grammar, a parser, spans and diagnostics, not a set of string conventions.
- The plan AST is the agent-system IR — it exists so components other than the executor can be written against the plan.
- Everything valuable about plans (review, refusal, diffing, replay, cost estimation) follows from having the representation, not from the model.
- Keyword-only arguments and explicit
$nresult references make mis-ordering a static error instead of a runtime surprise. - What the grammar cannot express is a design decision with teeth: it bounds what the agent can ever be asked to do.
- Rewriting a plan is an optimization and needs a legality condition — read-only, deterministic, idempotent — that most tool registries do not record.
A grammar for plans
The smallest useful agent DSL is a pipeline of named steps with keyword arguments and references to earlier results. It is deliberately not a general-purpose language: no user-defined functions, no arbitrary expressions, no loops without a bound. What it cannot express is as much of the design as what it can, because everything expressible is something the executor must be prepared to be asked for.
Writing the grammar down does three things at once. It defines the accepted set precisely, so "is this a valid plan" has an answer rather than an opinion. It gives the parser something to be written against, which is the difference between a rejection that says "expected an argument name or ) after ," and one that says "invalid plan". And where the model supports it, the grammar can be handed to a constrained decoder so malformed plans are never sampled at all — [[typed-tool-calls]] takes that up.
The notation below is EBNF, the same notation [[bnf-and-ebnf]] covers, because a plan language is a language and there is no reason to invent a second way of writing one down.
| plan | ::= | step { "|>" step } | A pipeline. Order is the plan; there is no other control flow. |
| step | ::= | NAME "(" [ arg { "," arg } ] ")" | NAME must resolve against the tool registry — a later phase, not this one. |
| arg | ::= | NAME ":" value | Keyword-only. Positional arguments would make a mis-ordered plan type-check. |
| value | ::= | STRING | NUMBER | BOOL | ref | compare | |
| ref | ::= | "$" NUMBER | The result of an earlier step. `$1` is the first step, so a forward reference is a resolution error rather than a runtime surprise. |
| compare | ::= | NAME op value | The only expression form. Deliberately not general: an agent that can write arbitrary expressions can write arbitrary predicates over data it should not read. |
| op | ::= | "==" | "!=" | "<" | "<=" | ">" | ">=" |
- 1.planapplying start symbol
- 2.step "|>" step "|>" stepapplying plan ::= step { "|>" step }
- 3.NAME "(" arg ")" "|>" step "|>" stepapplying step ::= NAME "(" [ arg ... ] ")"
- 4.search "(" query ":" STRING ")" "|>" step "|>" stepapplying arg ::= NAME ":" value
- 5.... "|>" filter "(" severity ">=" STRING ")" "|>" stepapplying value ::= compare
- 6.... "|>" summarize "(" style ":" STRING ")"applying arg ::= NAME ":" value
The plan as a tree, before anything runs
Parsed, the pipeline is a tree: a plan node with three step children, each with its arguments, and a data edge from each step to the one before it. Nothing has executed. No search has been issued, no document has been read, no token has been spent on a tool result. The plan is entirely inspectable at a moment when refusing it is free.
That is the whole argument, and it is the same argument [[why-ir-exists]] makes about compilers: the representation earns its keep by being the thing every other component is written against. A renderer shows the plan to a human. A differ compares two plans for the same request. A policy engine walks it. A cost estimator sums it. A replayer re-runs it. None of those can be written against a stream of tool calls that have already happened.
The spans matter as much here as they do in a compiler. When step 2's argument fails to type-check, the diagnostic can quote the exact substring of the model's output that produced it, which is what makes a repair prompt precise rather than a re-roll — the plan-level use of [[spans-and-ranges]].
Read it asEvery node here is a question a later phase will answer, and none of them are answered yet. Does search exist? Is severity a field the filter tool knows? May this caller read postmortems? The tree is structure and nothing else — the same discipline the parser keeps in [[what-parsing-does]], and for the same reason: a parser that also checked meaning could not report both kinds of problem in one pass.
What an explicit plan buys
Each row below is something you cannot do with a bare tool-calling loop, not because the loop is badly written but because there is no object to do it to. The plan exists only as a sequence of events that already happened.
The one that surprises people is diffing. Two runs of the same request against two model versions produce two plans; a structural diff shows that the second one added a step, changed an argument, or dropped a filter. Prose transcripts of the two runs show a wall of text that differs everywhere and means the same thing.
| Capability | With a plan AST | With a bare tool loop |
|---|---|---|
| Show the whole plan to a human first | Render the tree; approve or reject before step 1 | Only possible one call at a time, with no view of what follows |
| Refuse statically | Walk the tree, deny, return before any effect | The first steps have already run by the time the bad one appears |
| Diff two runs | Structural diff over step trees | Diff two transcripts, which differ everywhere |
| Replay | Re-execute the stored plan with no model call at all | Re-prompt and hope for the same sequence |
| Estimate cost or blast radius | Sum over the steps before running any | Discover it as you go |
| Rewrite (dedupe, reorder) | A pass over the tree, under a stated legality condition | Nothing to rewrite |
| Express something newtypical | Requires a grammar change and a release | The model can just do it |
Rewriting a plan is an optimization, with the same rules
Once a plan is a tree, the temptation to improve it arrives immediately: two identical searches, run one. A filter that could run before an expensive fetch, move it earlier. Three independent steps, run them in parallel. All three are real optimizations, and all three are exactly as illegal as the corresponding compiler transformations when the precondition does not hold.
The precondition is per-tool metadata that most tool registries do not carry: is this tool read-only, is it deterministic for a given argument tuple, is it idempotent, does it observe state another step in this plan writes. Those are the plan-level versions of the hasEffect and mayTrap guards our own optimizer uses on IR, and without them no rewrite has a legality argument — see [[optimization-legality]].
This is why plan optimization is rare in practice and should be. The saving is one tool call; the cost of a wrong rewrite is a duplicate charge, a stale result, or a message that never got sent. When the metadata is not there, the correct number of rewrites is zero.
search(query: "sev2 outages") |> summarize(style: "bullets") search(query: "sev2 outages") |> count()
search(query: "sev2 outages") |> summarize(style: "bullets") // uses $1 |> count() // also uses $1
Only if the tool registry declares search read-only and deterministic for a given argument tuple over the plan's lifetime, and no step between the two writes state that search reads. Under those conditions the second call cannot observe anything the first did not, so one result serves both — the plan-level instance of [[common-subexpression-elimination]], with the same "must dominate, no intervening write" conditions.
The index is updated by another step in the same plan; or the query is time-dependent, as any "recent" or "open" search is, so two calls a second apart legitimately differ; or the tool is billed per call and the caller is being charged for both. Any of those makes the two calls observably different, and collapsing them is a miscompilation of the plan, not an optimization of it.
How it works
The steps, in the order the compiler takes them.
- Define the plan grammar in EBNF, restricted to steps, keyword arguments, literal values, comparisons and references to earlier steps.
- Parse the model's output against it, producing a step tree with a span on every node pointing back into the raw text.
- Resolve each
$nreference to an earlier step index, rejecting forward and out-of-range references as resolution errors. - Resolve each step name against the tool registry and each argument name against that tool's declared parameters.
- Hand the resulting tree to whatever needs it: a renderer, a policy walker, a cost estimator, a dry-run executor, a differ, or the real executor.
- Store the plan alongside the run, so the same tree can be replayed later without a model call.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- The grammar grows an escape hatch — a
raworexprargument that takes arbitrary text — and every guarantee the DSL provided quietly evaporates through it, with no visible change to the architecture diagram. - The model consistently produces plans just outside the grammar (an extra step type, a positional argument) and the system reports "no plan produced" for a whole category of requests while looking healthy.
- Two plans differ in one argument and the diff is unreadable, because the plan was stored as the model's raw text rather than as the parsed tree.
- A plan-level dedupe pass collapses two calls to a tool that was not deterministic, and a "recent alerts" step returns an hour-old snapshot with no indication anything was reused.
- A step references
$3from step 2, the reference is resolved lazily at execution time, and the failure surfaces as an undefined value inside a tool rather than as a rejection before anything ran. - The grammar is rich enough to express plans the executor cannot actually run, so a plan parses, types, is approved by a human, and then fails in the middle with half its effects applied.
When it helps
- Multi-step work with effects, where a human wants to see the whole sequence before approving any of it.
- Anything that must be auditable or replayable, since the stored plan is both the audit record and the replay input.
- Comparing prompts, models or tool descriptions, because plan-level diffs are readable and transcript diffs are not.
- Domains where the useful actions genuinely are a small closed set — reporting, retrieval, data pipelines, deployment runbooks — so the grammar is not fighting the task.
When it hurts
- Open-ended exploration, where a closed grammar becomes a constant source of "the agent could not express what it wanted" failures.
- Small teams with fast-moving tools: every new capability now needs a grammar change, a parser change and a release, and the DSL becomes the bottleneck.
- Tasks whose next step genuinely depends on the previous step's content in ways no reference syntax captures, where a straight-line plan is a lie and an incremental loop is the honest model.
What it costs
Every one of these is paid by something.
- A grammar buys static rejection and precise diagnostics, and costs expressiveness — permanently. Everything the agent will ever do must be added to the language first, and each addition needs a parser change, a type, a policy rule and a test.
- A parsed tree buys review, diffing and replay, and costs a representation to design and version: plans stored last month must still parse after the grammar changes, which is a compatibility burden nobody expects at the start.
- Static
$nreferences buy resolvable data flow and cost a class of plan the model finds natural — "use the result from before" — which it will keep producing until the grammar or the prompt makes the reference form obvious. - Plan-level rewriting buys at most a few saved tool calls and costs the entire legality apparatus: per-tool purity and idempotence declarations that must be right, because a wrong one is a silent duplicate charge.
What else you could do
What a different compiler or language does instead, and when that is better.
- A bare tool-calling loop with per-call validation: no grammar, no plan object, maximum flexibility. Correct when tools are read-only or trivially reversible, and it gives up whole-plan review.
- An internal DSL — the plan is a set of typed constructor calls in the host language rather than a parsed string, generated through a schema-constrained decoder. Cheaper to build and impossible to inspect as text;
[[internal-vs-external-dsl]]is the same trade. - A fixed workflow graph where the model only picks edges. The strongest guarantees available, since the executable set is enumerable, and no ability to compose anything new — see
[[workflow-state-graph]]in Agentic AI. - A general-purpose language in a sandbox: maximum expressiveness, and the safety argument moves entirely from the grammar to the isolation boundary. A coherent choice with a different failure surface, not a lazier one.
See it for yourself
The flag, dump or tool that shows you this directly.
- Render the parsed plan back to text and diff it against the model's raw output. Anything the round trip loses is something your grammar is silently discarding.
- Keep a corpus of real model outputs and run the parser over it on every grammar change; the pass rate is your compatibility metric and it should never silently drop.
- Log the parsed plan as structured data, not as a string, so plan-level diffs and aggregate queries ("how often does step 1 call
search") are possible at all. - Dry-run mode: walk the plan resolving names and types, executing nothing. This is
-fsyntax-onlyfor plans and it should exist from day one. - For the compiler side of the analogy,
/compilers/pipelineshows the same tree-then-check discipline on real AtlasLang source, and the AST panel is the parser's actual output.
Plausible wrong readings
Stated the way a confident engineer states them.
- "The plan DSL is just a JSON schema." A schema constrains one value. A grammar plus a resolver constrains a whole tree, including references between steps, which is where the interesting errors are.
- "An explicit plan slows the agent down." The parse costs microseconds against a model call measured in seconds. What it costs is expressiveness, and that is the trade worth arguing about.
- "If the plan parses, the tools exist." Parsing establishes shape. Name resolution against the registry is a separate phase, and conflating them produces a runtime error where a diagnostic belonged.
- "We can optimize the plan the way a compiler optimizes IR." Only with the purity and idempotence metadata that makes the rewrite legal. Compilers have that information about their own instructions; a tool registry usually does not have it about tools.
Misconceptions
The claim, and what is actually true.
Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
Instead of letting the agent call tools directly, have it emit a small plan — search(...) |> filter(...) |> summarize(...) — in a language you have written a grammar for. Parse it into a tree before anything runs. Now you can show the whole plan to a person, reject it without side effects, compare it against yesterday's plan for the same request, and replay it later without calling the model again. None of that is possible once the calls have already happened.
practical
Keep the grammar small and resist the escape hatch. The first raw: or expr: argument someone adds to unblock a use case removes the property the language existed for, and it will not be removed later. Make arguments keyword-only so a mis-ordered plan is a static error. Make result references explicit ($1, $2) so data flow is resolvable before execution. Store the parsed tree, not the raw text, and keep a corpus of real outputs to re-parse whenever you change the grammar — that pass rate is the only warning you will get that a prompt change has moved the model off your language.
advanced
The deep question is where the language sits between two poles. Close it down far enough and the plan becomes a configuration value: fully analysable, fully reviewable, and unable to do anything you did not anticipate. Open it up far enough and it becomes a programming language, at which point the safety argument has to move from the grammar to a sandbox, because no analysis of an arbitrary program tells you what it will do. Most real agent DSLs drift toward the open end one feature at a time, and the drift is invisible because each feature is individually reasonable. The discipline is to notice when the language has crossed the line where static analysis stopped being decisive, and to move the guarantees to isolation deliberately rather than discovering later that they moved on their own.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
If you were asked this in an interview
- Design a plan language for an agent that can search, filter and send email. Show me the grammar, and tell me what you deliberately left out.
- What can you do with a plan AST that you cannot do with a stream of tool calls?
- Under what conditions may you deduplicate two identical tool calls in one plan, and what breaks if those conditions do not hold?