An agent receives the request "update the customer record" and calls a write tool. The reviewer sees only a summary, approves the operation, and later discovers that the target changed while execution was waiting. The problem is not a missing button. It is the lack of a boundary that shows the proposed effect before allowing the mutation.
To add a dry run to an AI agent, separate four steps: produce a typed plan, calculate a side-effect-free preview, approve that exact plan, and revalidate state immediately before execution. The model may propose the action, but only the authorized executor touches the external system.
This article is a spoke of the guide to when an AI agent should ask for human approval. The owner explains when a run should stop for a decision. This post answers how to build the preview and prevent an approval from being attached to different arguments. Validating tool calls in TypeScript covers the call schema; this article adds the effect contract and resource recheck.
The short answer
- Create a plan with the target, arguments, preconditions, and expected effect.
- Calculate the preview in a function or tool that has no write credentials or permission.
- Bind approval to the plan fingerprint, not to a generic "yes" request.
- Read the resource version again before execution and stop if it changed.
- Keep execution in a separate adapter with its own authorization and a zero-effects test for the preview path.
What does a dry run need to prove?
A useful dry run answers two different questions. First, did the runtime understand the arguments, target, and action policy? Second, can the system show what would change without changing the target? If it only prints the model's intention, it is not an effect preview.
The Kubernetes documentation describes a dry run with a property that is useful as a design reference: the request passes through validation and applicable stages, but is not persisted and has no side effects (Kubernetes, "API Concepts"). An agent does not gain that guarantee automatically. The application executor has to build it.
Use these criteria before calling a response a dry run:
| Criterion | Verification question |
|---|---|
| No mutation | Can the path work without a write credential or permission? |
| Explicit plan | Does the response identify the tool, target, arguments, and expected effect? |
| Preconditions | Does the preview keep the version or state that must remain valid? |
| Policy | Does the runtime validate authorization and risk before creating approval? |
| Separate execution | Is there a different path that is the only one able to change the system? |
The preview can fail too. A missing record, invalid schema, or denied policy should produce an explicit result. Do not turn a preview failure into permission to try the write anyway.
Why is dryRun: true in the same tool a weak boundary?
A dryRun parameter can be useful inside a trusted adapter, but it should not be the only barrier. The same function receives the intent, decides whether it is simulating, and retains potential access to the effect. A forgotten branch, a test that calls the wrong flag, or a new caller that omits the field can cross the boundary.
Prefer two capabilities with different contracts:
preview_update_contact(input) -> Plan
execute_update_contact(plan, approval) -> Result
The first may read current state and calculate a proposal. The second receives a validated plan, checks authorization, and revalidates state before writing. If the model can see only preview_update_contact, it cannot choose the write tool to skip review.
The OpenAI Agents SDK documents tools as functions executed by the application and supports needsApproval to pause a call before execution. That feature handles run interruption. It does not create an effect preview by itself or prevent the payload from being reconstructed differently after approval. Separating capabilities remains the runtime's responsibility.
How should you model an approvable plan?
The plan should be a data object, not the text the agent used to explain what it intends to do. Include only fields the executor can revalidate:
type ContactUpdatePlan = {
planId: string;
action: "update_contact";
targetId: string;
patch: { email?: string; name?: string };
before: { email: string; name: string };
after: { email: string; name: string };
expectedVersion: number;
requiresApproval: true;
};
before and expectedVersion matter for different reasons. The version detects a concurrent change even when the final value looks similar. The previous state helps show the reviewer what the operation intends to replace. planId should be a stable fingerprint of the normalized plan, including target, arguments, version, and applied policy.
Do not include a field such as approved: true produced by the model itself. Approval is a decision outside the plan. The executor should receive a token or record that the application issued after checking planId, the responsible user, expiration, and policy.
How do you implement preview and execution without sharing permission?
The fixture below uses a local Map to represent the external system. It runs with Node.js and does not measure a real provider. The point is to make it impossible for previewContactUpdate to change the map and make executeContactUpdate reject a stale plan.
Save it as agent-dry-run.mjs:
import assert from "node:assert/strict";
const contacts = new Map([
["contact-1", { version: 3, name: "Ana", email: "ana@old.example" }],
]);
function stableId(value) {
return JSON.stringify(value);
}
function previewContactUpdate({ targetId, patch }) {
const current = contacts.get(targetId);
if (!current) throw new Error("contact not found");
const allowed = new Set(["name", "email"]);
for (const field of Object.keys(patch)) {
if (!allowed.has(field)) throw new Error(`field not allowed: ${field}`);
}
const before = { name: current.name, email: current.email };
const after = { ...before, ...patch };
const identity = { targetId, patch, before, version: current.version };
return {
planId: `plan:${stableId(identity)}`,
action: "update_contact",
targetId,
patch,
before,
after,
expectedVersion: current.version,
requiresApproval: true,
};
}
function executeContactUpdate(plan, approval) {
if (!approval || approval.planId !== plan.planId) {
throw new Error("approval does not match the plan");
}
const current = contacts.get(plan.targetId);
if (!current || current.version !== plan.expectedVersion) {
throw new Error("stale plan: preview again");
}
contacts.set(plan.targetId, {
version: current.version + 1,
...plan.after,
});
return { status: "applied", planId: plan.planId };
}
const plan = previewContactUpdate({
targetId: "contact-1",
patch: { email: "ana@new.example" },
});
assert.equal(contacts.get("contact-1").email, "ana@old.example");
assert.equal(plan.after.email, "ana@new.example");
assert.throws(
() => executeContactUpdate(plan, { planId: "plan:wrong" }),
/approval does not match/,
);
assert.equal(contacts.get("contact-1").email, "ana@old.example");
const result = executeContactUpdate(plan, { planId: plan.planId });
assert.equal(result.status, "applied");
assert.equal(contacts.get("contact-1").version, 4);
assert.equal(contacts.get("contact-1").email, "ana@new.example");
assert.throws(
() => executeContactUpdate(plan, { planId: plan.planId }),
/stale plan/,
);
console.log("preview did not mutate; matching plan applied once");
Run the clean fixture with:
node agent-dry-run.mjs
The assert before execution shows that the preview did not change the email. The wrong token is rejected without an effect. After application, the same approval cannot be reused because the contact version changed. In a real service, replace the Map with a transactional read or an API that provides If-Match, an expected version, or another precondition mechanism.
This test proves the local contract. It does not prove that a production adapter makes no external calls during preview, that a concurrent API respects the version, or that the approval token is protected. Those points need integration tests and controls in the real system.
How do you bind approval to the plan instead of another action?
The flow should persist the full plan or a fingerprint that can retrieve it. Approval must then point to the same identity:
model -> normalized plan -> preview -> decision -> revalidation -> execution
If the agent recalculates after after approval, the person may approve one change while the executor applies another. The runtime should compare planId, target, material arguments, resource version, and policy. A mismatch requires a new preview or a safe rejection.
The OpenAI Agents SDK human-in-the-loop guide describes a run that pauses, returns interruptions, and resumes through the same RunState after a decision (OpenAI Agents SDK, "Human-in-the-loop"). Use that kind of state to carry the pending action, but keep authorization and the fingerprint in your own contract. A serialized snapshot is not proof of identity by itself.
The same applies to MCP. The TypeScript SDK exposes readOnlyHint, destructiveHint, idempotentHint, and openWorldHint as tool annotations (MCP TypeScript SDK, "server/mcp"). These annotations help a client understand risk, but they are hints. They do not replace deterministic authorization, a real dry run, or a sandbox when the server is not trusted.
When is a dry run the wrong answer?
Not every action needs a detailed preview. Reads, pure calculations, and disposable local changes can run in a simpler test environment. A preview can also mislead when the effect depends on a fast-changing system, an irreversible send, or a third party that does not provide a simulation API.
In those cases, choose the right barrier:
- use a sandbox or test account when the integration supports a real execution without production data;
- use explicit approval when the consequence is external and no faithful preview exists;
- refuse when the executor cannot identify the target, authority, or precondition;
- use idempotency and reconciliation when the call can fail after the effect starts.
Do not call a text response a preview if it was not derived from the state that execution will check. A summary can explain intent. A preview must represent the change and carry the conditions that make the application valid.
How this article was produced
Samuel Fajreldines is responsible for this article. The research combined current OpenAI Agents SDK, MCP TypeScript SDK, and Kubernetes documentation, live-result inspection, recent public discussions, and comparison with the site's existing approval and tool-validation posts. The fixture ran locally with Node.js using only an in-memory Map. It did not change a contact, email, permission, or external API.
I use RemoteCode as my own tool to keep context between Codex and Claude Code sessions. AI assistance helped find sources, compare pages, organize the draft, create the SVG illustration, and review the three languages. It did not run a real agent, test a production provider, or replace verification of your system's contracts.
Sources consulted
- OpenAI Agents SDK, "Tools", retrieved November 6, 2026.
- OpenAI Agents SDK, "Human-in-the-loop", retrieved November 6, 2026.
- MCP TypeScript SDK, "server/mcp", retrieved November 6, 2026.
- Kubernetes, "API Concepts: Dry-run", retrieved November 6, 2026.