Plan Mode: Approving What Your AI Agent Does Before It Does It
How a first-class Plan Mode in your agent SDK turns hallucinated side effects into reviewable tasks — and why this is different from human-in-the-loop bolted on after the fact.
Most “human in the loop” implementations look something like this. The agent runs. A tool is tagged “needs approval.” When it fires, the SDK pauses, shows a confirmation dialog, waits for the user to click yes, then resumes.
This works. It’s also not very useful, because the agent has already started by the time the dialog appears. Side effects earlier in the chain have already fired. Memory has been written. State has been mutated. The dialog is asking “okay to do one more thing?” — and the answer is meaningless because the world has already moved.
Plan Mode is what you get when you take human-in-the-loop seriously enough to make it a runtime mode.
The mode itself
A run in Plan Mode produces no side effects. It calls task.create as many times as it wants — that’s a memory write into a typed task list, which is allowed under tasks:write scope — and then it stops. Anything that would write to the world, hit an external API, or mutate user state refuses with a structured error.
# Plan run — no side effects.
plan = onto.run_sync(
"Plan a 3-day trip to Lyon.",
user_id="alice",
mode="plan_only",
consent_scopes=["tasks:write", "tasks:read"],
)
# plan.tasks is a list of typed Task objects.
# The agent proposed: [book_hotel, book_train, reserve_restaurant, ...]
# None of them have fired.
The human now has something to look at. A typed list of intended actions. Each task carries the tool name, the arguments, the cost estimate, and a model-generated rationale. They can approve all, approve some, edit arguments, ask the agent to re-plan, or just kill it.
# Human approves task by task. Then a second run, with execute scopes.
for task in plan.tasks:
if human_approves(task):
onto.execute_task(
task,
consent_scopes=["tools:web.fetch", "tools:http_request"],
)
The second run is where side effects happen. It carries the execute scopes; the Plan run did not.
Why a runtime mode, not a wrapper
You can build something like Plan Mode in any SDK by tagging tools and intercepting them. We tried this. It breaks in three places.
First, the agent doesn’t know it’s in “plan mode.” A wrapper sits outside the agent. The agent sees tools, calls them, and gets back “denied, ask the user first.” This produces a long agent retry loop and confused model behavior. The model writes apologies and tries to call the tool again with the same arguments. You waste tokens.
Second, the contract leaks. If a tool isn’t tagged, it fires. If a developer forgets the tag, it fires. If a new tool is added without the tag, it fires. The system depends on every tool author remembering a thing they could forget. The guarantee is best-effort.
Third, you can’t distinguish proposal from execution in the audit log. Both look like tool calls. You can grep for “denied” but you can’t reconstruct the human’s review trail.
Making Plan Mode a runtime mode fixes all three. The agent gets a system message saying “you are in plan_only mode; create tasks for what you would do.” The runtime refuses any tool whose required_consent isn’t covered. The audit log has separate Plan and Execute rows tied by plan_id.
The Tasks primitive
For Plan Mode to be useful, “what the agent proposes” has to be a thing you can hold. ONTO ships a Task primitive. A Task has:
tool: the tool name and arguments the agent wants to callrationale: the model’s own explanation of whycost_estimate: tokens or dollars, computed at plan timedepends_on: other task IDs this one needs firststate:proposed→approved|rejected|executed
This is data, not chat. You can render it in a UI. You can pipe it through your existing approval workflow. You can store the approval decision next to the task. You can replay the whole plan run when an auditor asks why a refund was issued.
Where this maps to real work
Three use cases that fail without something like Plan Mode:
Healthcare orders. A clinical copilot proposes “order ECG, order troponin panel, schedule cardiology consult.” Each is irreversible-ish (billing fires, the lab runs). The clinician must see and approve the list before any order is placed. Plan Mode → human approval → Execute with patient:order scopes.
Customer support refunds. An agent proposes “refund $500, send apology email, flag account for ops review.” For a free-tier account this needs human approval; for a gold tier it auto-approves under policy. Plan Mode lets you write that policy in plain code, outside the LLM.
Personal AI booking. An assistant proposes “book this hotel, reserve this restaurant, decline these two meetings.” The user reads the plan in their email, taps approve, and the second run fires. The user is in control of which actions happen, in what order, with what arguments.
In every case the structure is the same: the agent gets to think — including thinking about external state via reads — without getting to act. Acting is a separate run with separate scopes.
What about agents that just respond?
If your agent only answers questions and never writes to the world, you don’t need Plan Mode. A read-only chat agent runs with ["memory:read"] and no execute scopes. There is nothing to gate.
Plan Mode is for agents that do things. Once you cross that line — your agent calls APIs, books rooms, places orders, sends emails, files tickets, mutates the database — the question of “did the human approve this” goes from theoretical to load-bearing. Plan Mode is the line.
The escape hatch
For agents that need to act without a human (a cron job, a background processor), you skip Plan Mode entirely. Run with execute scopes. The agent acts. The audit trail records every action with its consent scopes, so when something goes wrong, you can answer “who authorized this” with “the cron job, here are the scopes it had.”
This is the right design: Plan Mode is opt-in per run, not a global setting. You pick the right mode for the right caller.
What you gain when this is built in
The honest answer for what changes is twofold:
-
You stop writing apology code. “Sorry, the agent did X and we didn’t mean it to” is a class of incident that mostly comes from agents acting before someone said yes. Plan Mode by construction eliminates the agent-acted-before-approval bucket.
-
Auditors stop asking the same question. Every regulated workflow eventually gets “show me the human approval for action Y.” With Plan Mode you produce the plan, the approval, and the execution rows. With a wrapper-based approach you produce a denied row and ask the auditor to trust you.
Neither of these is the kind of feature you’d find exciting at a hackathon. Both are the kind of feature that lets you sleep when your agent is in production.
Read the Plan Mode reference for the full task schema and execute pattern. The comparison page shows how Claude, OpenAI, and Google ADK handle planning differently.
Frequently asked questions
Isn't Plan Mode just human-in-the-loop with a new name?
Human-in-the-loop is a generic term — usually implemented as a middleware that pauses on tagged tools. Plan Mode is a first-class runtime mode that halts after task.create returns and refuses to fire side-effect tools until a second run with execute scopes. The guarantee lives in the runtime, not in a wrapper you can forget to apply.
What if the agent ignores plan mode and tries to call a tool anyway?
It can't. Policy Guard refuses any tool whose required_consent scopes aren't granted on the run. In plan_only mode, you grant tasks:write and read scopes — not execute scopes — so side-effect tools refuse structurally. The LLM can try; the runtime says no.
Does Plan Mode add latency?
Yes — by design. A typical use is: Plan run completes (cheap, no tool execution), human reviews tasks (variable, often async), Execute run completes. The Plan run is fast because no side-effect tools fire. The latency is the human review, which is the whole point.
The agent SDK where humans drive the state.
Plan Mode, typed memory, per-call consent scopes, and open-weight defaults. Open source under MIT or Apache-2.0.