Agentssimplified

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.

The question

Why should an agent produce a plan in a small language instead of just calling tools one at a time?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

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.

What this phase may assume or do

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 $n result 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.

A minimal plan language
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 ":" valueKeyword-only. Positional arguments would make a mis-ordered plan type-check.
value::=STRING | NUMBER | BOOL | ref | compare
ref::="$" NUMBERThe 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 valueThe only expression form. Deliberately not general: an agent that can write arbitrary expressions can write arbitrary predicates over data it should not read.
op::="==" | "!=" | "<" | "<=" | ">" | ">="
Deriving search(query: "outage postmortems") |> filter(severity >= "sev2") |> summarize(style: "bullets")
  1. 1.planapplying start symbol
  2. 2.step "|>" step "|>" stepapplying plan ::= step { "|>" step }
  3. 3.NAME "(" arg ")" "|>" step "|>" stepapplying step ::= NAME "(" [ arg ... ] ")"
  4. 4.search "(" query ":" STRING ")" "|>" step "|>" stepapplying arg ::= NAME ":" value
  5. 5.... "|>" filter "(" severity ">=" STRING ")" "|>" stepapplying value ::= compare
  6. 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]].

The plan AST for the pipeline above
AST — only what later phases match on
Plan“search(...) |> filter(...) |> summarize(...)”— Three steps, sequential. The order IS the control flow.
├── Step 1 · search“search(query: "outage postmortems")”
│ └── arg query: str“"outage postmortems"”
├── Step 2 · filter“filter(severity >= "sev2")”
│ ├── input $1“$1”— A data edge to step 1. Resolvable statically: a reference to $3 here would be a forward reference and a resolution error.
│ └── arg condition: predicate“severity >= "sev2"”
└── Step 3 · summarize“summarize(style: "bullets")”
├── input $2“$2”
└── arg style: str“"bullets"”

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

typicalThe last row is the cost, and it is the reason plan DSLs are not universal: a bare loop lets the model compose tools in ways nobody anticipated, which is sometimes exactly the value being bought. Systems with reversible, low-blast-radius tools frequently choose the loop deliberately. The choice tracks how bad the worst step is, not which design is more sophisticated.

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.

Plan AST versus a bare tool-calling loop
CapabilityWith a plan ASTWith a bare tool loop
Show the whole plan to a human firstRender the tree; approve or reject before step 1Only possible one call at a time, with no view of what follows
Refuse staticallyWalk the tree, deny, return before any effectThe first steps have already run by the time the bad one appears
Diff two runsStructural diff over step treesDiff two transcripts, which differ everywhere
ReplayRe-execute the stored plan with no model call at allRe-prompt and hope for the same sequence
Estimate cost or blast radiusSum over the steps before running anyDiscover it as you go
Rewrite (dedupe, reorder)A pass over the tree, under a stated legality conditionNothing to rewrite
Express something newtypicalRequires a grammar change and a releaseThe 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.

Deduplicating two identical tool calls in one plan
Before
search(query: "sev2 outages") |> summarize(style: "bullets")
search(query: "sev2 outages") |> count()
After
search(query: "sev2 outages")
  |> summarize(style: "bullets")   // uses $1
  |> count()                       // also uses $1
Legal only when

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.

Illegal when

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 $n reference 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 raw or expr argument 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 $3 from 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 $n references 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-only for plans and it should exist from day one.
  • For the compiler side of the analogy, /compilers/pipeline shows 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.

A plan language means writing a full compiler.
It means writing a parser for a grammar that fits on a page, plus a resolver. The frontend of a plan DSL is a few hundred lines; there is no optimizer, no backend and no codegen unless you choose to add them.
The AST is an implementation detail of the executor.
It is the interface every other component uses. The executor is one of its consumers, and usually the least interesting one — the reviewer, the differ and the policy walker are why the tree exists.
Adding expressive power to the plan language is free.
Every construct is something the executor must implement, the policy engine must reason about, and the reviewer must understand. Expressiveness is paid for four times.

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.

simplifiedThe grammar shown is a straight pipeline with no branching, no loops and no error handling. Real plan languages grow conditionals and retry semantics almost immediately, and each addition makes static analysis of the plan meaningfully harder — a conditional means a step may or may not run, so cost and blast radius become ranges rather than numbers.
typicalMainstream agent frameworks in 2026 mostly do not use an external plan DSL; they use per-call schema validation inside a loop. The DSL approach is common where effects are irreversible and review is mandatory, which is a minority of deployments and a majority of the ones with a compliance requirement.

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?

Connections