Rehearsing an Agent's Actions Before They Touch Production — Designing a Zero-Side-Effect Dry-Run Layer
Some accidents survive shadow mode and canaries: the very first time an agent touches an external API. This is the design and TypeScript implementation of a zero-side-effect dry-run layer you can bolt onto Antigravity's parallel agents, with the real numbers from running six sites autonomously.
One day I was running an agent that rewrites production Remote Config values, and my blood ran cold. All I wanted was to flip one campaign flag to true. But the agent had decided to tidy up a few stale keys along the way, and its plan included deleting settings that were still live. I happened to be watching the log that time, and stopped it just before it applied. But if it had been an overnight job, the display logic across the wallpaper apps I run might have broken all at once.
As a solo developer, I hand most routine work to Antigravity's parallel agents, but I never quite shook the fear around one category of action: when an agent changes something external. Code changes can be reviewed and rolled back. But AdMob settings, App Store Connect metadata, production Remote Config values, Cloudflare DNS — once you hit those, they are hard to un-hit.
Woodworkers have a saying: "Measure as often as you like; cut only once." Check the dimension as many times as you need, but make the cut just once. Ever since I started delegating external actions to agents, I've wanted to rebuild that instinct as a mechanism. This note is the record of the zero-side-effect dry-run layer I built for exactly that. It includes a minimal, working TypeScript implementation, and I hope it helps anyone else operating agents that carry external side effects.
Shadow mode and canaries can't protect the "first time"
Shadow execution and canaries are well-known safety devices for agents, and at first I assumed they were enough. But in practice, I found neither one protects the very first time an agent touches production.
Shadow execution runs an agent alongside production while discarding its output. It's great for observation, but it doesn't apply to side-effecting actions. The shadow of "rewrite Remote Config" is a contradiction: if you don't write, there's nothing to observe; if you do write, production has already changed. It works for read-heavy agents, but it can't protect a write agent's first run.
Canaries apply a change to a small slice of users or targets first, then widen it if nothing breaks. That helps after application. But what scared me was when the application itself was wrong. In the Remote Config example, the plan to "delete a live setting" was the error — and applying that error to 1% or to 100% is still an error. Canaries narrow the blast radius of a correct change; they don't reject an incorrect change in advance.
What I needed was a way to see, completely but without any side effects, exactly what was about to happen — before it happened. That is a dry-run. In engineering terms, I wanted the relationship between terraform plan and terraform apply, available for any external action an agent might take.
A dry-run only works if side effects are funneled
When you try to add a dry-run after the fact, you usually hit the first wall immediately: side effects are scattered all over the code, and there's no single point where "stop here and you stop every change." If each agent tool calls fetch or hits an SDK on its own, adding a dry-run means sprinkling conditionals across every site — and you will miss one. The moment a single call slips through unguarded, the dry-run lies.
So the starting point of the design is not the dry-run mechanism itself. It is funneling every externally-mutating action through one place. I call this the side-effect boundary. Tools never execute side effects themselves; they declare to the boundary, "I want to make this change." In production mode the boundary executes it; in dry-run mode it records it without executing. Only once this is unified does a dry-run become trustworthy.
// effect.ts — the type for a "planned action" representing an external side effectexport type EffectKind = | "http" // a write request to an external API | "kv-write" // a write to KV / Remote Config, etc. | "file-write" // a write to a repo or asset | "delete"; // a deletion of something (split out — it's the most dangerous)export interface Effect { kind: EffectKind; // a one-line summary a human can read to understand what will happen summary: string; // structured detail used when actually executing target: string; // e.g. "remote-config/campaign_flag" before?: unknown; // value before the change (only if obtainable) after?: unknown; // value after the change // the executor, called on real application. Never called during a dry-run. commit: () => Promise<void>;}
The key is that an Effect bundles together the declaration of intent and the function that actually does it (commit). The declaration is what you show a human during the dry-run; commit runs only on real application. Locking both into the same object makes it far harder to drift into "the plan said delete X, but execution deleted Y."
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Get the full TypeScript implementation of a SideEffectBoundary that funnels all external mutations through one point, plus the Effect recorder that captures the plan
✦Understand how a dry-run solves a different problem than shadow mode and canaries, and how to decide which one a given situation needs
✦Learn the real operating numbers and gotchas — how unintended production changes went from a few a month to zero after adding the dry-run layer
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Next, implement the boundary that receives Effects. It carries a mode and switches behavior: execute immediately in production, record only in dry-run.
// boundary.ts — the side-effect boundary itselfimport { Effect } from "./effect";export type RunMode = "dry-run" | "commit";export class SideEffectBoundary { private planned: Effect[] = []; constructor(private mode: RunMode) {} // Tools never execute side effects directly — they always call this. async apply(effect: Effect): Promise<void> { if (this.mode === "dry-run") { // Do not execute. Just record it as a plan. this.planned.push(effect); return; } // Production mode: record before executing (leave a trace first). this.planned.push(effect); await effect.commit(); } // Deletions are high-risk, so make a surge in their count detectable. get deleteCount(): number { return this.planned.filter((e) => e.kind === "delete").length; } get plan(): readonly Effect[] { return this.planned; }}
On the tool side, you rewrite code that used to hit an SDK directly so it goes through boundary.apply() instead.
// tools/remote-config.ts — a tool declaring a side effectimport { SideEffectBoundary } from "../boundary";export async function setCampaignFlag( boundary: SideEffectBoundary, key: string, value: boolean, client: RemoteConfigClient,): Promise<void> { const before = await client.get(key); // reads aren't side effects — always run them await boundary.apply({ kind: "kv-write", summary: `Change Remote Config ${key} from ${before} to ${value}`, target: `remote-config/${key}`, before, after: value, commit: async () => { await client.set(key, value); }, });}
With this shape, the tool no longer needs to know whether it's a dry-run. It just tells the boundary what it wants to do; whether to execute or record is the boundary's decision. The separation of concerns lands cleanly.
Showing the plan to a human as a diff
A recorded plan is useless if you can't review it before applying. I format mine to read like a code-review diff. Distinguishing risk visually — red for deletions, yellow for writes — lets me catch the seed of an accident even when skimming an overnight job's log in the morning.
// render.ts — format a plan into human-readable diff textimport { Effect } from "./effect";export function renderPlan(plan: readonly Effect[]): string { if (plan.length === 0) return "(no changes)"; const lines: string[] = []; const deletes = plan.filter((e) => e.kind === "delete").length; lines.push(`Planned changes: ${plan.length} (deletions: ${deletes})`); lines.push(""); for (const e of plan) { const mark = e.kind === "delete" ? "[DELETE]" : "[CHANGE]"; lines.push(`${mark} ${e.target}`); lines.push(` ${e.summary}`); if (e.kind !== "delete" && e.before !== undefined) { lines.push(` before: ${JSON.stringify(e.before)}`); lines.push(` after: ${JSON.stringify(e.after)}`); } } return lines.join("\n");}
In practice I send this diff to Slack or a local log, and if the deletion count exceeds a threshold I set in advance, I require human approval before proceeding to real application. The Remote Config near-miss I opened with is exactly the case this caught. My intent was a single write, but the plan contained four deletions. When the numbers don't add up, you know something is off.
A two-phase design that applies the very same plan
Once a human approves the plan built during the dry-run, you apply it to production. The crucial point here is that the plan shown during the dry-run and the actions executed in production must be the same list of Effects. Rebuilding them separately invites the worst kind of drift: something different from what you reviewed gets executed.
// run.ts — two-phase run: dry-run first, then commit the same plan after approvalimport { SideEffectBoundary } from "./boundary";import { renderPlan } from "./render";export async function runWithRehearsal( agentTask: (b: SideEffectBoundary) => Promise<void>, approve: (planText: string, deletes: number) => Promise<boolean>,): Promise<{ applied: boolean; planText: string }> { // Phase 1: dry-run. Build the plan with zero side effects. const dry = new SideEffectBoundary("dry-run"); await agentTask(dry); const planText = renderPlan(dry.plan); // Let a human (or a rule) decide whether to approve the plan. const ok = await approve(planText, dry.deleteCount); if (!ok) { return { applied: false, planText }; } // Phase 2: execute the approved plan as-is. // Note: do NOT re-run agentTask — commit the Effects captured in phase 1. const commit = new SideEffectBoundary("commit"); for (const effect of dry.plan) { await commit.apply(effect); } return { applied: true, planText };}
There's a fork in the road here. In phase 2, do you commit the sameEffects, or do you re-run agentTask in commit mode? I chose the former. The latter looks simpler, but an agent's judgment wobbles, and a second run can produce a different plan than the first. If the plan you carefully reviewed and the actions actually applied diverge, the dry-run was pointless. Apply the exact Effect list you reviewed. That is the heart of the two-phase design.
Read consistency: not letting the dry-run lie
This is the part I stumbled over in production, and it's rarely spelled out in the docs. Because a dry-run causes no side effects, it builds its plan on the assumption that the state of the world it read won't change until the moment of application. In reality, time passes between the dry-run and the real apply. During the minutes or hours you wait for approval, external state can shift.
Here's what bit me. At dry-run time, a Remote Config key existed, so the plan was "update it." But while it sat awaiting approval, another job deleted that key. When commit ran after approval, it tried to update a key that no longer existed and errored out. The plan wasn't a lie — the world had moved.
The fix was to give each Effect a precondition, checked immediately before commit. I confirm that the before value read during the dry-run still holds just before applying, and abort if it differs. It's optimistic locking, carried into an agent's external actions.
// add a precondition to Effectexport interface Effect { kind: EffectKind; summary: string; target: string; before?: unknown; after?: unknown; // called just before commit. Returning false means the world changed — abort. precondition?: () => Promise<boolean>; commit: () => Promise<void>;}
// verify the precondition at commit time in boundary.tsasync apply(effect: Effect): Promise<void> { if (this.mode === "dry-run") { this.planned.push(effect); return; } if (effect.precondition) { const stillValid = await effect.precondition(); if (!stillValid) { throw new StalePlanError( `Plan is stale: the precondition for ${effect.target} changed`, ); } } this.planned.push(effect); await effect.commit();}
Since adding this small step, "the world differs from what I reviewed" accidents have gone to zero. A dry-run does not promise the future; it only captures the world at the moment the plan was made. Asking, right before applying, whether that premise still holds — only with that does the dry-run become a safety device you can trust.
The numbers after running it
After folding this dry-run layer into the autonomous operation of six sites, several numbers changed clearly.
Before, agent jobs carrying external side effects produced unintended production changes about two or three times a month. Most were minor and quickly reversible, but some — Remote Config, store metadata — were hard to undo. In the two months since adding the dry-run layer and a deletion threshold, the number of bad actions that reached production is zero. Plans stopped before reaching production — caught by diff review and by the precondition combined — came to eleven.
I also measured the time review takes. Back when I read full-text logs instead of formatted diffs, I spent four to five minutes per job confirming. After formatting deletions in red and putting counts in the header, normal cases take about a ten-second skim, and I only stop when counts cross the threshold. The lighter a safety device is, the more likely you keep using it for real. Heavy safety devices quietly decay into "approve anyway" and lose their meaning.
On cost: because a dry-run executes nothing, it consumes almost no external API write quota. But building the dry-run plan still incurs the agent's inference cost, so running dry then real means two passes and higher inference spend. I make only high-risk actions (deletions, production setting changes) two-phase, and skip the dry-run for safe, read-heavy jobs.
Where to put a dry-run, and where not to
Finally, the criteria I use by situation. Making every action a dry-run is unrealistic on both cost and operational load.
I always dry-run hard-to-reverse external side effects: rewriting production Remote Config, updating store metadata, changing DNS or Cloudflare settings, and deletions of any kind. Once executed, these are costly to recover — sometimes impossible. The old "cut only once" rule maps exactly onto this territory.
I skip the dry-run for easily reversible actions and read-heavy jobs. A Git commit can be reviewed and rolled back; draft article generation never reaches production directly. Wrapping those in a dry-run just doubles the work for almost no accident prevented.
If I had to name a single axis, it's: "Can this be undone?" Reversible actions are well served by canaries and rollback. Reserve the pre-application rehearsal of a dry-run for the actions that can't be undone. Shadow mode is for observation, canaries for controlling post-application blast radius, dry-runs for blocking errors before application — the three don't compete; they guard different moments.
Handing external actions to an agent is a question of trust and, at the same time, a question of design. Having a human judge trustworthiness every single time doesn't scale; handing it over entirely is frightening. What fills the gap, I've come to feel, is a mechanism that checks one more time before applying. Measure as often as you like; cut only once. That old rule is what I'm translating into code here. I hope it offers a useful design hint to anyone else facing external side effects.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.