Agentstypical

A Plan Is a Program

A model-generated plan is source code in an untrusted language written by an unreliable author. That single reframing hands you a whole compiler frontend of techniques — a grammar, a parser, name resolution, a type checker and an authorization pass — and tells you the order to run them in.

The question

A model just handed my system a plan to execute. What should happen to it before anything runs?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

Four representations in sequence, and the whole lesson is that they are different. A byte string sampled from a model, with no structural guarantee at all. A parsed plan: a tree of steps, each with a tool name and an argument map. A resolved and typed plan: the same tree with every tool name bound to a registered tool and every argument checked against that tool's declared parameters. An authorized plan: the typed tree plus a recorded policy decision for each step. Each form exists because the previous one could not answer the next question — which is the same reason [[compiler-phases]] exist at all.

What this phase may assume or do

An executor is entitled to assume exactly what the earlier phases established and nothing more: that the plan parsed, that every step names a tool that exists, that every argument matches its declared parameter type, and that a policy decision has been recorded for every step. It is entitled to assume nothing about intent — not that the plan solves the user's problem, not that the arguments are the ones the user meant, not that the model understood the request. No parser and no type checker can establish any of those, and reading a passing validation as a statement about intent is the specific mistake this module exists to prevent.

Key points

  • A model-generated plan is a program in an untrusted language written by an unreliable author; both properties are ordinary conditions for a compiler frontend.
  • Parse the plan into a data structure; never hand the raw output to something that executes strings.
  • Keep the phases distinct — parse, resolve, type, authorize — because each rejects a different class of problem and can name which one it rejected.
  • The authorization phase has no compiler equivalent and must come after typing, because a policy engine needs resolved names and typed arguments to decide anything precise.
  • Once a step executes, no later validation helps. The ordering is a property of irreversibility, not of taste.
  • A validated plan is well-formed and permitted. It is not correct, and nothing in this pipeline claims it is.

An untrusted language, an unreliable author

Two properties make a model's output exactly the kind of input a compiler frontend is built for. It is untrusted: the bytes may have been influenced by a web page, a document, a tool result or an email that the operator never saw, so the plan is attacker-reachable even when the user is not an attacker. And it is unreliable: the author is a sampler, not a program, so a plan that was well-formed a thousand times can be malformed on the next call for no reason you can reproduce.

Both of those are ordinary conditions in this domain. A compiler assumes its input is hostile and malformed by default; that is why it has a grammar rather than a set of string operations, and a diagnostic phase rather than a crash. The techniques transfer without modification because the situation is the same situation.

What does not transfer is the *authority* question. A C compiler that accepts a program has not authorized anything — running the output is a separate decision a human makes. An agent that accepts a plan usually executes it in the same breath, which is why this module adds a phase compilers do not have, and puts it last: [[plan-validation]] covers the ordering, and it matters for the same reason phase ordering matters anywhere.

From a request to an effect in the worldtypical
  1. Request + contextyou write it
    A natural-language instruction plus whatever context was assembled around it.
  2. Model outputyou write it
    A byte string, sampled token by token. No structural guarantee unless the decoder was constrained.
    A proposal. Nothing more — nothing has been established about it yet.
  3. Parsed planbuild time
    A tree of steps, each with a tool name and an argument map, with source spans.
    Structure: which argument belongs to which step, and in what order the steps run.
    The surrounding prose, and any meaning that lived only in it.
  4. Resolved planbuild time
    The same tree with every tool name bound to an entry in the tool registry.
    That the names refer to something that exists.
  5. Typed planbuild time
    The same tree with a checked type on every argument.
    That every value is of the kind the tool declared it wanted.
  6. Authorized planbuild time
    The typed tree plus a recorded policy decision per step, with the principal and the resource.
    That this caller may do this, to this resource, right now.
  7. Executionrun time
    A running loop: a step index, a store of intermediate results, and effects in the world.
    Effects. Some of them are not reversible.
    Every remaining opportunity to say no.

Read it asRead the last row first. Once a step has run, no later check helps — a refund has been issued, an email has been sent, a row has been deleted. That is the entire argument for putting all of this in front of the first tool call rather than defensively inside each tool, and it is why the phase order here is not a style preference.

Parse it. Do not evaluate it.

The shortest path from a model's output to behavior is to treat the output as code in a language you already have an interpreter for — a shell command, a SQL string, a Python snippet, a JavaScript expression. That path is short because it skips every phase above. It also hands an untrusted author the full capability of that interpreter, which is [[command-injection]]-shaped whatever the surface looks like.

The alternative is not "sanitize the string". Sanitizing is a filter written against the attacks you thought of; a grammar is a definition of what is admissible, and everything outside it is rejected by construction rather than by enumeration. This is the same argument that makes a lexer plus a parser the right way to read a language and a pile of regular expressions the wrong way — see [[what-parsing-does]].

The practical form of the rule: the plan's representation should be a data structure your code constructed, never a string your code passed to something that executes strings. If a step needs to run a query, the step carries the query's *parameters*, and your code builds the query. The plan says what; your code says how.

The same plan, two trust postures
1// Evaluating: the model's output IS the program, and the interpreter
2// grants it everything the interpreter can do.
3const plan = await model.complete(prompt)
4await shell(plan) // any command
5await db.query(plan) // any statement
6eval(plan) // any expression
7
8// Parsing: the output is data until a parser says otherwise, and
9// what it can express is bounded by the grammar, not by the runtime.
10const parsed = parsePlan(raw) // grammar; spans; diagnostics
11if (!parsed.ok) return reject(parsed.diagnostics)
12const typed = checkPlan(parsed.plan, toolRegistry)
13if (!typed.ok) return reject(typed.diagnostics)
14const allowed = authorize(typed.plan, principal)
15if (!allowed.ok) return refuse(allowed.denials)
16await execute(allowed.plan) // only registered tools, typed args

The second block is longer, and that length is the point: each line is a phase that rejects a different class of problem and can say which one it rejected. The first block has exactly one failure mode, and it is "something happened".

What each representation can answer

typicalThis layering describes systems that route model output through a tool registry with declared schemas — the mainstream shape of tool-calling in 2026. Systems that hand the model a code interpreter deliberately collapse the first four rows into one and rely on [[agent-sandboxing]] for containment instead; that is a coherent design with a completely different failure surface, and the tradeoff is isolation strength versus validation precision, not one being careless.

The reason to keep these as distinct representations, rather than one validate() function, is that each one answers a question the previous one structurally cannot — and each one can therefore produce a diagnostic that names the actual problem. "Step 2 argument amount expected an integer, found the string "twenty"" is a message a type checker can produce and a regular expression cannot.

It also decides where a fix belongs. A plan that consistently fails to parse is a prompt or decoder problem. A plan that parses but names tools that do not exist is a tool-description problem. A plan that types cleanly but is denied by policy is either a scoping problem or exactly the system working. Those three have nothing in common except that all of them look like "the agent failed" from the outside.

Which question each form of the plan can answertypical
RepresentationCan answerCannot answer
Model output (bytes)Nothing structural. Whether it is non-empty.Whether it is even a plan
Parsed planWhat the steps are, their order, and which argument belongs to whichWhether any of those tools exist
Resolved planThat every tool name binds to a registered toolWhether the arguments make sense for it
Typed planThat every argument matches the declared parameter typeWhether this caller may run it
Authorized planThat a policy decision exists for every step, with a principalWhether the plan achieves what was asked
Execution traceWhat actually happened, in order, with resultsNothing about the plan that did not run

The limit, stated plainly

Everything above establishes that the plan is *well-formed and permitted*. None of it establishes that the plan is *right*. A plan can parse, resolve, type-check and pass policy, and still delete the wrong project because the model resolved "the old one" to the wrong id. The frontend of a compiler has exactly the same limit: a program that type-checks can still compute the wrong answer, and no one finds that surprising.

What this buys is that the remaining failures are semantic rather than structural, and semantic failures are the ones a human review, a dry run, or an [[approval-gates-and-risk]] step can actually catch. Reducing the failure surface to the class a human can review is the goal; eliminating it is not on offer.

The second limit is worth naming separately because it gets forgotten: validating the plan says nothing about the *results* the tools return. A tool result re-enters the model's context as text, and if that text came from a document or a web page it is untrusted input to the next planning step. [[tool-output-untrusted]] and [[indirect-prompt-injection]] live on that edge, and no amount of plan validation covers it, because the plan being validated has not been generated yet.

How it works

The steps, in the order the compiler takes them.

  • The plan language is given a grammar — a schema, an EBNF, or a set of typed step constructors — that defines exactly which plans are expressible.
  • The model's raw output is parsed against that grammar, producing a step tree with spans back into the output so diagnostics can point at the offending text.
  • Each step's tool name is looked up in a registry; an unresolved name is a diagnostic, not a runtime error, which is the plan-level version of [[name-resolution]].
  • Each argument is checked against the tool's declared parameter type, producing a typed plan or a list of type diagnostics.
  • A policy engine walks the typed plan and records a decision per step, given the calling principal and the resources the arguments name.
  • Only then does an executor walk the plan, running steps in order and storing intermediate results for later steps to reference.
  • Rejections at any phase are returned as structured diagnostics, which the caller may show to a human, log, or feed back to the model as a repair prompt.

How it breaks

What the engineer observes when it goes wrong — not what goes wrong internally.

  • A plan executes a tool nobody expected, because the plan text was concatenated into a prompt template and a document in the context redirected it. The trace shows a legitimate-looking call with no upstream user request.
  • The system silently does nothing for a class of requests, because the model's output stopped parsing after a prompt change and the parse failure is being swallowed as "no plan produced".
  • An argument arrives as the string "20" where the tool wanted an integer, the tool coerces it, and a refund of twenty dollars becomes a refund of twenty cents — or two thousand — with no error anywhere.
  • Every step passes validation and the plan still does the wrong thing, and the team responds by adding more schema constraints, which cannot help because the problem was never structural.
  • Authorization is checked inside each tool rather than over the plan, so a plan whose fourth step will be denied still runs its first three, and the first three were the destructive ones.
  • Diagnostics from the rejection are fed straight back to the model, which repairs the shape and re-proposes the same disallowed action, and the loop spends the whole budget without progressing.

When it helps

  • Any system where a model's output causes an effect that cannot be undone — payments, deletions, messages to third parties, infrastructure changes.
  • Multi-step plans, where the value of an explicit representation compounds: you can show the whole plan to a human before any of it runs, rather than approving one call at a time with no view of what follows.
  • Debugging a misbehaving agent, because a plan that failed at a named phase tells you where to look, and "the agent did something weird" does not.
  • Systems that must produce an audit trail, since a validated plan plus a per-step policy decision is exactly the record an audit wants.

When it hurts

  • Exploratory and conversational uses where the model is answering rather than acting. A validation frontend over a plan language nobody needs is pure cost.
  • Genuinely open-ended work, where any plan grammar rich enough to express what is needed is close to a general-purpose language — at which point isolation is the better lever than validation.
  • Very tight latency budgets. Every phase is cheap, but a rejection followed by a repair round-trip costs another full model call, and that is the expensive part.

What it costs

Every one of these is paid by something.

  • An explicit plan representation buys inspection, diffing, replay and static rejection, and costs a grammar to design, a parser to maintain, and a hard ceiling on what the agent can express — every capability must be added to the language before it can be used.
  • Rejecting malformed plans buys safety and costs task completion rate: some rejected plans were correct and merely written oddly, and the user experiences that as the system refusing to work.
  • Repair-and-retry buys back much of that completion rate and costs latency, tokens and determinism — the retried plan is a different plan, so a bug reproduces only probabilistically.
  • Splitting validation into distinct phases buys precise diagnostics and costs implementation surface: four passes to write, four to test, and four places for the tool registry's shape to leak.

What else you could do

What a different compiler or language does instead, and when that is better.

  • Sandbox instead of validate: give the model a real interpreter inside a container with no network and no credentials, and rely on isolation rather than a grammar. Better for open-ended work, much weaker at explaining what the agent was trying to do.
  • Single-step tool calling with no plan at all: validate each call as it comes and never build a plan object. Simpler, and correct for short interactions; it gives up whole-plan review, which is exactly what you want before a destructive sequence.
  • A fixed workflow with the model choosing only branches: the strongest guarantee available, because the set of possible executions is enumerable. It gives up the flexibility that motivated using an agent.
  • Human approval on every effectful step, with no static validation at all. Sound, and it does not scale past a low call rate — see [[hitl-vs-hotl-escalation]] in Agentic AI for where the line usually falls.

See it for yourself

The flag, dump or tool that shows you this directly.

  • Log the raw model output alongside the parsed plan for every request. The pair is what tells you whether a failure was generation or parsing, and keeping only the parsed form makes that undiagnosable.
  • Log the rejection phase as a distinct field — parse, resolve, type, policy — and chart the four over time. A shift in the mix after a prompt or model change is the earliest signal you get.
  • Diff two plans for the same request across model versions. If your plans are a data structure this is a structural diff; if they are prose it is not possible, which is itself an argument for the representation.
  • Replay a stored plan against a dry-run executor that resolves and types every step but performs no effects. This is the plan-level equivalent of compiling with -fsyntax-only.
  • For the compiler half of the analogy, run /compilers/pipeline on an AtlasLang program and watch the same four questions get answered in the same order by real code.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "The model returns JSON, so the output is structured." JSON is a syntax, not a schema and not a type system. {"tool": "delete_everything"} is perfectly well-formed JSON.
  • "Validation makes the agent safe." Validation makes the agent's output well-formed and permitted. Whether it is the right action is a semantic question no validator answers.
  • "If the plan type-checks, the arguments are correct." They are of the correct *kind*. An integer amount that is off by a factor of a hundred type-checks perfectly.
  • "This is just input validation with extra steps." Input validation checks a value against a predicate. This resolves names, checks types across a whole tree, and records authorization decisions — the extra steps are the ones that catch the interesting failures.
  • "We can skip the parser because the model almost always gets the format right." Almost always is the property that makes it dangerous: the failures are rare, correlated with unusual inputs, and therefore concentrated exactly where you are least able to reason about them.

Misconceptions

The claim, and what is actually true.

A plan generated by a model is data, not code.
It is data right up until something acts on it, at which point it is a program that chooses which effects happen. The distinction that matters is whether you parse it or evaluate it.
Structured output settings mean the plan cannot be malformed.
Constrained decoding can make a plan syntactically impossible to malform, which is a real and strong guarantee. It says nothing about whether the tool exists, the arguments are sane, or the action is permitted.
You validate once, at the boundary.
There are at least four boundaries with different owners: the parser owns shape, the resolver owns names, the type checker owns argument kinds, the policy engine owns authority. Collapsing them produces one unactionable error message.

Go deeper

The same idea at increasing depth. Stop wherever it stops being useful.

overview

When a model produces a plan for your system to run, treat it the way a compiler treats a source file someone emailed you: assume it may be malformed and may be hostile. Give the plan language a grammar. Parse the output into a data structure instead of handing the text to something that executes text. Look up the tools it names, check the argument types, decide whether this caller is allowed to do it, and only then run it — in that order, because after a step runs it is too late to reject it.

practical

Make the rejection phase a first-class field in your logs. Four counters — parse failures, unresolved tools, type errors, policy denials — will tell you more about a misbehaving agent than any amount of transcript reading, because each one points at a different owner: the decoder, the tool descriptions, the schemas, the policy. When you feed a rejection back to the model for repair, cap the retries and never feed back a policy denial as though it were a format complaint; the model will happily rewrite the shape of an action it is not allowed to take, and you will burn the budget watching it.

advanced

The genuinely hard part is not the frontend, it is the loop. Validation covers one turn: output in, plan out. But a tool result becomes context for the next turn, and a result sourced from a document, a web page or another agent is untrusted text that participates in generating the next plan. That makes the trust boundary a cycle, not a line, and a cycle cannot be secured by a frontend alone — the containing techniques are capability scoping per step, non-escalating result handling, and treating every tool output as tainted for the rest of the session. Compilers get to assume their input stops arriving. Agents do not.

How much this depends on

Nothing in this domain is true of every compiler. These say how much.

typicalThe four-phase shape describes tool-calling systems with a registry of declared schemas, which is how mainstream agent frameworks are built as of 2026. Code-interpreter agents deliberately collapse the phases and rely on sandboxing instead; the guarantees are then containment guarantees rather than validation guarantees, and the two are not interchangeable.
simplifiedThe pipeline here is drawn as a straight line. Real agents loop: a tool result feeds the next planning step, so the phases run again on new output with new context every iteration. The linear drawing is correct for one turn and misleading about the whole session, where the interesting attack surface is the feedback edge.

If you were asked this in an interview

  • A model returns a plan as text. Walk me through everything that should happen to it before the first tool call, and say what each step rejects.
  • Why should authorization run after type checking rather than before, and when is that ordering wrong?
  • Your agent passes every schema check and still took a destructive action. Which of your phases was supposed to catch that, and what is the honest answer?

Connections