What is Plan-then-Execute?
Plan-then-Execute is an AI design pattern for LLM apps and agents, in the Agent behavior group. Write the full plan first and execute steps with a simpler model or with code.
Planejar e Executar
Plan-then-Execute is an AI design pattern for LLM apps and agents, in the Agent behavior group. Write the full plan first and execute steps with a simpler model or with code.
Write the full plan before acting, then execute the steps with code or a simpler model. It is for long tasks where the order of steps can be decided up front and deciding on every turn is expensive.
A travel itinerary written the night before: in the morning you follow the list instead of choosing every street at the crossing.
Diana runs a browser agent that downloads duplicate bills from utility company portals. Every task makes the same moves on different portals: log in, find the bills area, filter the month, download the PDF, go back.
The agent did everything as a ReAct Loop, with the large model deciding each click by looking at the screen. It worked, but each task cost dozens of calls to the larger model, and every so often it got distracted by a banner and forgot the requested month.
On top of that, the plan was never visible. When a task failed, there was no way to tell whether the problem was the strategy or the execution of a step. The team scrolled through traces of 40 clicks to find the moment the strategy went off the rails.
The question this pattern answers: how do you separate deciding what to do from doing it, so each part is cheaper, more predictable, and easier to inspect?
Split the work into two phases:
If a step fails or the result does not match expectations, execution stops and the planner is called again with the current state. That replanning needs a cap: two or three rounds, not an endless loop of new plans.
The plan is an artifact: you can show it to a person before execution, store it on the trace, and compare it across runs. Xu and colleagues named a nearby idea ReWOO (2023): reason without observing on every step, so you do not pay the large model on every observation. The emphasis here is the plan as an object that code validates and the executor fulfills.
Task: “download the August bill from utility company X, account 4419”. The planner returns:
{
"goal": "PDF of the 08/2026 bill for account 4419",
"steps": [
{ "action": "open", "target": "https://portal.x.example/login", "success_check": "url contains /home" },
{ "action": "fill", "target": "#account", "value": "4419", "success_check": "account field = 4419" },
{ "action": "click", "target": "a.bills", "success_check": "page title is Bills" },
{ "action": "click", "target": "a[data-month='2026-08']", "success_check": ".pdf file exists and name contains 2026-08" }
]
}
The executor is code: open, fill, click, download. The last step’s check is not “the page looks right”. It is “a PDF exists whose name contains 2026-08”. If the portal changed the selector, step 3 fails, code stops, and the planner receives the current HTML. Without that check, the executor would mark success on a July receipt.
Suppose, for illustration, 8 clicks per portal and a large-model call per click in ReAct. The happy path of this pattern is one planner call plus 8 code actions. The large model only comes back when a step breaks.
flowchart TD
T(["1. Task"]):::io --> P(["2. Planner: capable model"]):::llm
P --> PL[("3. Structured plan")]:::store
PL --> H{"4. Needs approval?"}
H -->|yes| A((("Person reviews the plan"))):::human
H -->|no| E["5. Execute step"]:::tool
A --> E
E --> V{"6. Did the step succeed?"}
V -->|yes, more steps| E
V -->|no| P
V -->|last one| OUT(["7. Result"]):::io
schema Step { id: int, action: "open" | "click" | "fill" | "download" | "extract",
target: string, value: string?, success_check: string }
schema Plan { goal: string, steps: list[Step] }
function run(task):
plan = llm(PLANNER_PROMPT, task, model="large") -> Plan # 2 and 3
trace.span("plan", plan)
if plan.has_side_effects(): human.approve(plan) # 4
for step in plan.steps:
result = execute(step) # 5. code or small model
if not check(step.success_check, result): # 6
plan = llm(REPLANNER_PROMPT, task, plan, step, result, model="large") -> Plan
return run_from(plan, state) # replanning, with a cap
return collect_results() # 7
function execute(step):
if step.action in ["open", "download"]: return browser.do(step) # plain code
return llm_with_tools(EXECUTOR_PROMPT, [browser], step, model="small")
action lets code refuse hack_the_portal.| Naive | Correct | |
|---|---|---|
| Plan | A free-text list | Schema with action, target, and check |
| Step success | The executor thinks so | A fact checked in code |
| Failure | Improvise mid-path | Stop, replan, respect the cap |
| Side effect | Runs along with the reads | The plan goes through a person first |
| Aspect | What happens | How it shows up in the trace |
|---|---|---|
| Calls | One to the planner, few or none to the large model during execution | a plan span followed by step spans |
| Cost | Drops when execution uses code and small models | plan cost versus step cost |
| Latency | The plan adds time up front; execution gets faster | plan duration |
| Failure: wrong plan | An impossible or out-of-order step | failure on the first step and a replan |
| Failure: rigid plan | The environment changes and the plan does not keep up | many replans per task |
| Failure: weak check | A step marked done without succeeding | successful execution and a wrong result in the eval |
{ "task": "Download the August bill from utility portal X", "expected_steps_max": 8, "check": "PDF downloaded with reference month 08" }
Build two sets. In the first, the portal is as the planner expects: the happy path should not call the large model again. In the second, you break the selector on purpose: the system should replan up to the cap and fail with the state, not invent a PDF. If the second set “passes” without the right file, the check is theatre.
plan span with the full plan, and step spans with result and check.The plan is the last list of actions the system will carry out. Treat it as code: validate the schema, refuse unknown actions, and do not let page content rewrite the steps. If the executor reads HTML, that HTML is data, not instruction.
If the plan includes an effect (pay, send, delete), human approval looks at the whole plan at once, not each click. It is cheaper for the person and harder for an injection to hide a step in the middle of thirty observations.
| Pros | Cons |
|---|---|
| The large model only decides the strategy | The plan can go wrong when the environment changes |
| Cheap execution with code and small models | One planning call at the start |
| The plan is visible, reviewable, and comparable | Requires a plan schema and per-type executors |
| Helps resist instructions coming from content | Unpredictable environments need many replans |
The tabs build the plan with the capable model as structured output and execute each step with code, calling the planner again when a step fails. Python and TypeScript write that cycle as a loop. LangGraph writes the same cycle as edges: execute, replan, or finish.
Python. Use this when execution is mostly code and the large model only plans.
from typing import Literal
import anthropic
from pydantic import BaseModel
client = anthropic.Anthropic()
class Step(BaseModel):
action: Literal["open", "click", "fill", "download", "extract"]
target: str
value: str | None = None
success_check: str
class Plan(BaseModel):
goal: str
steps: list[Step]
def make_plan(task: str, state: str = "") -> Plan: # 2 and 3
return client.messages.parse(
model="claude-opus-5-5", max_tokens=4000,
system="You plan tasks on web portals. Use at most 12 steps. Do not execute anything.",
messages=[{"role": "user", "content": f"Task: {task}\nCurrent state: {state or 'start'}"}],
output_format=Plan,
).parsed_output
def run(task: str, browser, max_replans: int = 2) -> dict:
plan, replans, done = make_plan(task), 0, []
i = 0
while i < len(plan.steps):
step = plan.steps[i]
result = browser.do(step.action, step.target, step.value) # 5. code
if browser.check(step.success_check): # 6
done.append(step)
i += 1
continue
if replans == max_replans:
return {"status": "failed", "done": done, "failed_step": step, "result": result}
replans += 1
state = f"Completed steps: {[s.model_dump() for s in done]}. Failed: {step.model_dump()} -> {result}"
plan, i = make_plan(task, state), 0 # replan from the current state
return {"status": "ok", "steps": len(done), "replans": replans} # 7What you gained: one large-model call per task on the happy path, and a readable plan on the trace. What you lost: steps that require reading the screen need an executor with a model; add one when code is not enough.
TypeScript. Use this when the plan must go through human approval before execution.
import Anthropic from "@anthropic-ai/sdk";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import { z } from "zod";
const client = new Anthropic();
const Plan = z.object({
goal: z.string(),
steps: z.array(z.object({
action: z.enum(["open", "click", "fill", "download", "extract"]),
target: z.string(),
value: z.string().nullable(),
success_check: z.string(),
})).max(12),
});
type Plan = z.infer<typeof Plan>;
async function makePlan(task: string, state = "start"): Promise<Plan> {
const { parsed_output } = await client.messages.parse({
model: "claude-opus-5-5",
max_tokens: 4000,
system: "You plan tasks on web portals. Use at most 12 steps. Do not execute anything.",
messages: [{ role: "user", content: `Task: ${task}\nCurrent state: ${state}` }],
output_config: { format: zodOutputFormat(Plan) },
});
if (!parsed_output) throw new Error("planner returned no plan");
return parsed_output;
}
export async function run(task: string, browser: Browser, approve: (p: Plan) => Promise<boolean>) {
const plan = await makePlan(task);
if (!(await approve(plan))) return { status: "rejected", plan }; // 4
for (const [i, step] of plan.steps.entries()) {
const result = await browser.do(step); // 5
if (!(await browser.check(step.success_check))) { // 6
return { status: "needs_replan", failedAt: i, result, plan };
}
}
return { status: "ok", plan }; // 7
}What you gained: the person approves the whole plan at once, before any click. What you lost: replanning is left to whoever calls run; handle needs_replan with a cap.
Python + LangGraph. Use this when the plan, the execution, and the replan must be nodes the trace shows apart.
from typing import Literal
from langchain.chat_models import init_chat_model
from langgraph.graph import END, START, StateGraph
from pydantic import BaseModel
from typing_extensions import TypedDict
class Step(BaseModel):
id: str
action: str
success: str
class Plan(BaseModel):
steps: list[Step]
class State(TypedDict):
task: str
steps: list[dict]
cursor: int
note: str
replans: int
result: str
planner = init_chat_model("anthropic:claude-opus-5-5", temperature=0).with_structured_output(Plan)
def plan(state: State) -> dict:
# 2 and 3. capable model, full plan, no execution
drafted = planner.invoke([
("system", "Write the plan as steps. Do not execute anything."),
("human", f"{state['task']}\n{state['note']}"),
])
return {"steps": [s.model_dump() for s in drafted.steps], "cursor": 0, "replans": state["replans"] + 1}
def execute(state: State) -> dict:
step = state["steps"][state["cursor"]]
ok, detail = run_step(step) # 5. executor in code
if not ok:
return {"note": f"{step['id']} failed: {detail}"} # 6. back to the planner
return {"cursor": state["cursor"] + 1, "note": ""}
def route(state: State) -> Literal["execute", "plan", "done"]:
if state["replans"] > 3:
return "done"
if state["note"]:
return "plan"
if state["cursor"] >= len(state["steps"]):
return "done" # 7
return "execute"
graph = StateGraph(State)
graph.add_node("plan", plan)
graph.add_node("execute", execute)
graph.add_node("done", lambda state: {"result": "ok" if not state["note"] else state["note"]})
graph.add_edge(START, "plan")
graph.add_edge("plan", "execute")
graph.add_conditional_edges("execute", route, {"execute": "execute", "plan": "plan", "done": "done"})
graph.add_edge("done", END)
app = graph.compile()run_step is yours. Step 4, plan approval, goes in as interrupt on the plan node when the effect is sensitive. See Human-in-the-Loop.
What you gained: replanning is an edge, with a cap on replans. What you lost: a graph for a cycle the Python tab’s for already made obvious, if you never resume midway.
| Construction | Who writes the cycle | On this page |
|---|---|---|
| Hand-written loop | You | Python tab |
| Runner with plan approval | The SDK; the pause is yours | TypeScript tab |
| Graph | You, on the edges | LangGraph tab |
Plan-then-Execute is an AI design pattern for LLM apps and agents, in the Agent behavior group. Write the full plan first and execute steps with a simpler model or with code.
In the real-world scenarios Deep research with citations, Legacy code migration, and Browser agent for repetitive tasks.