Build for the Day the Agent Breaks Something: Keeping Blast Radius Small
Once you let an Antigravity agent touch production, the problem stops being how smart it is and becomes how much it wrecks when it slips. Here is a four-layer containment design that shrinks blast radius, with TypeScript and real numbers.
When you run a 50-million-download app business by yourself, the chores you'd love to hand off pile up fast: bulk dependency upgrades, keeping localized resource files in sync, batch-fixing the same crash class that Crashlytics keeps surfacing. The work I've been grinding by hand since 2014 is exactly the work I most want to automate.
So the first time I let an Antigravity agent do a bulk rewrite across a production repo, I froze in front of a diff that had broken 38 files at once. The agent's reasoning was wrong in exactly one place — but the design fanned that one decision out to every file, so the damage spread instantly. Tests passed. What broke was an initialization order the tests never looked at.
That day taught me that in agent operations, the thing that bites you first isn't intelligence — it's blast radius: how far a single failure spreads. Even a smart agent will be wrong sometimes. The real question is how much it drags down with it when it is. Below is a containment design for keeping that radius structurally small, with the implementation alongside.
Measure Blast Radius on Three Axes
"That feels risky" is a feeling, not a design. Before talking about containment, you need a ruler for damage. I use three axes.
Spread: how many files, records, or external calls a single run changes
Cost: the token billing that run consumes, plus the money wasted when it fails
MTTR: wall-clock time to roll back from broken to working
Reviewing the 38-file incident on these axes: spread was 38 (excessive), cost was about ¥120 per run (trivial), and recovery took 27 minutes of git revert plus build verification (heavy). So the real risk of that task was never money — it was excessive spread and long recovery time. Splitting the axes shows you which knob to tighten.
Start with a small helper that estimates damage before the run.
// risk-estimate.ts — estimate blast radius before an agent runexport interface RiskEstimate { spread: number; // number of targets changed (files/records/API calls) costYen: number; // expected token billing (yen) mttrMinutes: number; // expected recovery time on failure (minutes)}export interface RiskPolicy { maxSpread: number; maxCostYen: number; maxMttrMinutes: number;}export function classifyRisk(est: RiskEstimate, policy: RiskPolicy) { const reasons: string[] = []; if (est.spread > policy.maxSpread) reasons.push(`spread ${est.spread} > ${policy.maxSpread}`); if (est.costYen > policy.maxCostYen) reasons.push(`cost ¥${est.costYen} > ¥${policy.maxCostYen}`); if (est.mttrMinutes > policy.maxMttrMinutes) reasons.push(`mttr ${est.mttrMinutes}m > ${policy.maxMttrMinutes}m`); // empty reasons → auto-approve; otherwise route to a human approval gate return { autoApprove: reasons.length === 0, reasons };}
The key is keeping the logic dumb: if any one of the three axes exceeds its threshold, stop auto-execution and route to approval. Fancy scoring makes it impossible to explain later why a run was blocked. My rule is simple — I don't put unexplainable automated decisions in production.
Layer 1: See the Diff Before You Apply It
The outermost layer is a dry-run that pins down what will change before anything changes. Antigravity agents cause side effects through tool calls, so the leverage is splitting each tool into a "plan" mode and an "apply" mode.
// dry-run-wrapper.ts — split side-effecting tools into plan/apply modestype Plan = { op: "write" | "delete"; path: string; preview: string }[];interface ToolContext { mode: "plan" | "apply"; plan: Plan;}export async function writeFile(ctx: ToolContext, path: string, content: string) { if (ctx.mode === "plan") { // don't write — just stage the change in the plan ctx.plan.push({ op: "write", path, preview: content.slice(0, 200) }); return { staged: true }; } await fs.writeFile(path, content, "utf-8"); return { staged: false, written: true };}// run one agent turn in plan mode, measure spread, then applyexport async function runWithDryRun(agentTurn: (ctx: ToolContext) => Promise<void>, policy: RiskPolicy) { const ctx: ToolContext = { mode: "plan", plan: [] }; await agentTurn(ctx); const spread = ctx.plan.length; if (spread > policy.maxSpread) { return { applied: false, plan: ctx.plan, blockedBy: `spread ${spread}` }; } await agentTurn({ mode: "apply", plan: ctx.plan }); return { applied: true, plan: ctx.plan };}
The official tool-design guidance doesn't stress this, but what works in production is running the same turn twice: plan first, count spread, then apply. With a plan phase in place, the 38-file incident would have shown me "about to change 38 files" and I'd have stopped it. It costs a bit under 2x the tokens, but trading ¥120 of a run to erase a 27-minute recovery risk is cheap insurance.
One caveat: if a tool is non-deterministic (depends on time, randomness, or external state), running it twice breaks down. I snapshot tool inputs at the start of the run and feed the same values to both phases. Get sloppy here and you hit the worst betrayal — "the plan said 3, the apply changed 12."
✦
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
✦Wrap autonomous agent runs in four layers — dry-run, scoped permissions, circuit breaker, dead-letter queue — so a single runaway action can't cascade across production, with TypeScript you can drop in as-is
✦Learn from a real incident running a 50M-download app where one bulk rewrite corrupted 38 files, and how to measure blast radius along three concrete axes: file count, cost, and rollback time
✦Set circuit-breaker thresholds, dead-letter re-injection rules, and the human-approval boundary using real numbers that contain damage without strangling your own productivity
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.
Counting files in a dry-run doesn't help if the territory the agent is allowed to touch is huge. Layer 2 shrinks permission scope physically. Asking "please don't touch the production DB" in a prompt is not containment. It's containment only once touching it is structurally impossible.
What I use is an allowlist of writable paths declared per task, with every out-of-scope write rejected at runtime.
// scope-guard.ts — reject side effects outside the allowed scope at runtimeimport path from "node:path";export class ScopeGuard { constructor(private allowGlobs: string[], private root: string) {} private isAllowed(target: string): boolean { const rel = path.relative(this.root, path.resolve(this.root, target)); if (rel.startsWith("..")) return false; // outside root → reject immediately return this.allowGlobs.some((g) => this.match(rel, g)); } assertWritable(target: string) { if (!this.isAllowed(target)) { throw new ScopeViolation(`write rejected: ${target} is outside the allowed scope`); } } private match(rel: string, glob: string): boolean { const re = new RegExp("^" + glob.replace(/\*\*/g, ".*").replace(/\*/g, "[^/]*") + "$"); return re.test(rel); }}export class ScopeViolation extends Error {}// example: only let it touch localization resourcesconst guard = new ScopeGuard(["locales/**/*.json"], process.cwd());guard.assertWritable("locales/ja/common.json"); // OK// guard.assertWritable("src/payment/stripe.ts"); // throws ScopeViolation
You'd think narrowing scope handcuffs a smart agent, but in practice it was the opposite. The tighter the allowed area, the shorter and more accurate the agent's plan, and the "while I'm here" edits to unrelated files just vanish. I keep payments (Stripe integration) and production DB migrations out of any automated scope no matter how tedious they are, because when those break, recovery is measured in hours, not minutes. Drawing the line by recovery time rather than spread is my second judgment axis.
Layer 3: Stop the Cascade with a Circuit Breaker
Dry-run and scope contain a single run. But when you run dozens of tasks overnight, you hit the same failure cascading. An external API is down, or the model is reliably wrong on a particular input, and the agent dutifully retries, manufacturing billing and garbage diffs.
This is where a circuit breaker belongs: after a streak of failures, open the circuit and stop executing for a while.
// circuit-breaker.ts — cut off execution after consecutive failurestype State = "closed" | "open" | "half-open";export class CircuitBreaker { private failures = 0; private state: State = "closed"; private openedAt = 0; constructor( private threshold = 3, // open after 3 consecutive failures private cooldownMs = 10 * 60_000, // stop executing for 10 minutes ) {} async run<T>(task: () => Promise<T>): Promise<T> { if (this.state === "open") { if (Date.now() - this.openedAt < this.cooldownMs) { throw new CircuitOpen("circuit open: waiting out cooldown"); } this.state = "half-open"; // let exactly one probe through } try { const result = await task(); this.reset(); return result; } catch (err) { this.recordFailure(); throw err; } } private recordFailure() { this.failures += 1; if (this.failures >= this.threshold) { this.state = "open"; this.openedAt = Date.now(); } } private reset() { this.failures = 0; this.state = "closed"; }}export class CircuitOpen extends Error {}
Picking the threshold is the hardest part in practice. I start from 3 consecutive failures with a 10-minute cooldown. Set it to 1 and transient network jitter stops you too often; set it above 5 and billing balloons while broken. In real numbers, after dropping the threshold to 3 on the batch job that aggregates AdMob revenue reports, wasted billing on upstream-outage days fell from about ¥4,800 to ¥600 per month — roughly an 8x reduction. The half-open probe means it auto-recovers the moment the outage clears, so you never have to reset the circuit by hand.
Layer 4: Don't Discard Failures — Park Them
Flushing the tasks a circuit breaker stopped straight into an error log is a waste. Layer 4 parks failures in a dead-letter queue and re-injects them safely once the cause is fixed.
// dead-letter.ts — park failed tasks and re-inject selectivelyinterface FailedTask { id: string; payload: unknown; error: string; attempts: number; firstFailedAt: number;}export class DeadLetterQueue { private queue: FailedTask[] = []; park(id: string, payload: unknown, error: Error, attempts: number) { this.queue.push({ id, payload, error: error.message, attempts, firstFailedAt: Date.now() }); } // re-inject only tasks under 5 attempts that are NOT the same error class drain(isRetryable: (t: FailedTask) => boolean): FailedTask[] { const retryable = this.queue.filter((t) => t.attempts < 5 && isRetryable(t)); this.queue = this.queue.filter((t) => !retryable.includes(t)); return retryable; } report() { const byError = new Map<string, number>(); for (const t of this.queue) byError.set(t.error, (byError.get(t.error) ?? 0) + 1); return [...byError.entries()].sort((a, b) => b[1] - a[1]); // most frequent first }}
The biggest payoff from the dead-letter queue wasn't the automated re-injection — it was the failure skew that report() surfaces. When one error message dominates the top, that's not a transient hiccup; it's a design flaw. I review that skew weekly and route the top three to root-cause fixes instead of re-injecting them. Park failures instead of discarding them and your agent operation's weak spots show up as statistics.
The re-injection trap: re-queue unconditionally and you'll retry forever against an unfixed cause, slugging it out with the circuit breaker. So drain always carries a "don't re-queue the same error class" filter. Skip it and the dead-letter queue restarts the very cascade you stopped.
How Many Layers to Apply — Avoiding Over-Defense
I've shown four layers, but applying all of them to every task is over-defense. Containment trades off against productivity; clamp too hard and you end up approving everything yourself, which defeats the automation. I pick layers using the same three axes from the start — spread, cost, recovery time.
Read-only, zero spread (log aggregation, report generation): auto-run with no containment, circuit breaker only
Medium spread, easy recovery (localization updates, doc formatting): dry-run + scope guard, two layers
Wide spread or heavy recovery (bulk dependency upgrades, schema changes): all four layers plus a mandatory human approval gate
Payments / production DB: excluded from automated scope entirely — the agent only drafts
Decide this routing up front and automating a new task becomes "pick which box it goes in." In my experience, about 80% of tasks land in the read-only or medium-spread boxes, and only around 20% need full containment. The flip side: handle that 20% carelessly and you get the 38-file incident.
Containment isn't a mechanism for distrusting your agent. I think of it as the footing that lets you widen, with evidence, the range you can comfortably delegate. Intelligence climbs every year, but you keep the blast-radius design in your own hands. That's the balance I've struck for running large production systems solo.
If you're starting today, I recommend this order.
Pick one task you already automate (or want to), and estimate damage on all three axes: spread, cost, recovery time
If recovery exceeds 10 minutes, or spread crosses your allowed scope, add just the two layers — dry-run plus scope guard — first
When you move it to an overnight batch, add the circuit breaker (3 failures, 10 minutes) and dead-letter queue, and review the report() skew weekly
This order lets you tighten only the parts that hurt, instead of carrying all four layers from day one. That's how I've widened the range of work I can automate, a little at a time, without producing incidents.
Pick one automated task you're running today and estimate its blast radius on the three axes. Which layers you need tends to fall out of that on its own. Thanks for reading.
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.