Collecting Guardrails Across Projects Into One Place — A Thin Wrapper Around the Antigravity SDK
When you copy the same safeguards into every project, you eventually fix one and leave the other stale. Here is a design that builds a single thin wrapper around the Antigravity SDK to centralize cost caps, allowed tools, and output validation — from someone running several apps in parallel.
It started when an agent in one project wrote a file into a directory it was never supposed to touch. Tracing it back, the allowed-tools restriction I had set in another project simply was not present in this one. As I copied the same safeguards from project to project, one place had been left behind, still stale.
As an indie developer running several apps and several sites in parallel, the code that calls the Antigravity SDK scatters everywhere. Release automation for the App Store and Google Play, AdMob report rollups, article generation — I hand all of it to agents. And scattered code always drifts apart, bit by bit. Fix one place and forget another. When migrating to a new model, you fix three call sites and miss the fourth. Carefulness alone could not prevent that drift.
So I stopped calling the SDK directly. I built exactly one thin wrapper and routed every project through it. Here I share the design that collected cost caps, allowed tools, and output validation into that wrapper.
Why a "thin" wrapper
The first thing I watched was not making the wrapper too thick. Build a heavy abstraction that re-wraps every SDK feature, and the wrapper has to chase every SDK update, which only increases maintenance.
What I aimed for was a thin layer that passes SDK calls through almost as-is and only injects the cross-cutting safeguards I want applied everywhere. Concretely: create a single point that every call must pass through, and check cost, tools, and output there. Do not hide the SDK's own API; only forbid the dangerous defaults. That line is the condition for a wrapper that lasts.
The cross-cutting safeguards I wanted to collect came down to three.
Cost caps. Structurally forbid runs with no cap, and stop on both the estimate and the actual cost.
Allowed tools. By default permit only read operations; do not let writes through without explicit permission.
Output validation. Do not adopt output that fails the schema, and keep it out of the production downstream.
These three are gotchas I want enforced identically in every project. That is exactly why collecting them in one place — rather than copying them around — was worth it.
Forbid dangerous defaults with types
Half the value of the wrapper actually lives in the type design. Call the SDK directly and the safeguard arguments are "optional." Optional means forgettable. In the wrapper, I made the must-not-forget arguments required.
import { createAgent, type AgentRunResult } from "@antigravity/sdk";// Safeguards every call must pass — all requiredinterface SafeRunOptions { task: string; // Do not make the cost cap optional. Forbid uncapped runs at the type level maxCostUsd: number; // Allowed tools must be explicit. An empty array means "permit nothing" allowedTools: string[]; // Output schema validation is required. No adopting output without it validate: (output: unknown) => boolean; // Receive the model as a pinned version, not an alias model: `gemini-3.5-flash-${string}` | `gemini-3.5-pro-${string}`;}export async function safeRun(opts: SafeRunOptions): Promise<AgentRunResult> { // This is the single point every call passes through; later sections add guards return runWithGuards(opts);}
Not making maxCostUsd optional paid off. In the SDK, omit the cap and it runs unbounded. Make it required in the wrapper's type, and code that forgets the cap will not even compile. You move safeguards from "verify by eye that it is written" to "it will not build unless you write it."
Constraining model to a pinned version with a template-literal type follows the same idea. Code that passes only the alias gemini-3.5-flash becomes a type error, forcing every call to be written with a version.
✦
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 overall design for routing every call through one thin wrapper so cost caps, allowed tools, and output validation live in a single place
✦Learn a type design that forces required arguments and forbids dangerous defaults, plus the fail-closed implementation (TypeScript) that paid off in production
✦See the operating rules that consolidated safeguards scattered across five projects into one wrapper and drove policy-update misses to zero
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.
The guard body is collected in runWithGuards. The design principle is fail-closed. When a situation is ambiguous, do not let it through — stop.
async function runWithGuards(opts: SafeRunOptions): Promise<AgentRunResult> { // 1. Validate the requested tools first const unknownTools = opts.allowedTools.filter(t => !KNOWN_SAFE_TOOLS.has(t)); if (unknownTools.length > 0) { throw new GuardError(`unknown tool requested: ${unknownTools.join(", ")}`); } // 2. Compare the cost cap against an estimate before running const estimate = await estimateCost(opts.task, opts.model); if (estimate.usd > opts.maxCostUsd) { throw new GuardError( `estimated $${estimate.usd} exceeds cap $${opts.maxCostUsd}`); } const agent = createAgent({ model: opts.model, tools: opts.allowedTools, // Also set the SDK-side hard cost cap, doubly hardCostCapUsd: opts.maxCostUsd, }); const result = await agent.run(opts.task); // 3. Validate output. If it fails, do not adopt it (fail-closed) if (!opts.validate(result.output)) { throw new GuardError("output failed schema validation; refusing to adopt"); } return result;}
What matters here is doubling the cost cap. Beyond rejecting on the pre-run estimate, I set the SDK's hardCostCapUsd to the same value. Estimates can miss, so I want it to stop on the actual in-flight cost too. One alone cannot prevent a run from blowing past the estimate.
Making output validation fail-closed also came from a painful experience. I used to "just save it and fix it later" even for output that failed validation, but that "later" never came. Once I decided that output failing validation is not adopted but raised as an exception, I could avoid the accident of broken artifacts flowing downstream.
My default cost cap sits at $0.5 per run in my operations. Light article generation and report rollups fit within that, and an estimate exceeding it functions as a threshold to suspect that something is off. Some projects have heavy work, so I raise it individually with the override described below, but I recommend keeping the default low. A low default is the safety net that stops a runaway first.
Update policy in one place
The biggest benefit of consolidating into the wrapper was that policy updates happen in one place. The list of allowed tools, the default cost cap, the validation rules — collect these into constants and helpers inside the wrapper, and changing direction touches a single location.
// Cross-project default policy — update only here and it applies everywhereexport const KNOWN_SAFE_TOOLS = new Set([ "read_file", "search_code", "run_tests", // "write_file" is not included by default; only projects that need it permit it explicitly]);export const DEFAULT_COST_CAP_USD = 0.5;// Provide common validators by nameexport const validators = { nonEmptyJson: (o: unknown) => typeof o === "object" && o !== null, hasRequiredKeys: (keys: string[]) => (o: unknown) => typeof o === "object" && o !== null && keys.every(k => k in o),};
Take migrating to a new model, for example. I used to fix each project's calls in turn and once missed the fourth site. Now, update the allowed range of the wrapper's model type, and every project still passing an old version surfaces all at once as type errors. A missed rollout becomes impossible.
Dropping write_file from the allowed tools by default was deliberate too. Writing is the most dangerous operation, so I made it something only the projects that need it permit explicitly. The "wrote into an off-limits directory" accident from the opening can now be prevented structurally by this default.
How to allow project-specific overrides
The dilemma of centralization is how to handle per-project circumstances. Share everything and a project with special requirements feels cramped. I kept the defaults common while leaving room for each project to override explicitly.
What matters here is making sure an override is not a "silent loosening." Add a dangerous tool via extraTools and that code stays clearly visible as a diff. At review time, "why does this project permit writing?" is obvious at a glance. Loosening is not forbidden, but it can only happen in a visible form. That design was the reconciliation between centralization and flexibility.
How to test the wrapper itself
When you collect safeguards into one place, the blast radius of that one place breaking also grows. That is exactly why I wrote more thorough tests for the wrapper than for any other code.
What I prioritized was testing that the guards "stop correctly." Does an estimate over the cap raise? Does passing an unknown tool get rejected? Does output failing validation never get adopted? For safeguards, the tests for the stopping side mattered more than the passing side. A fail-closed design only has meaning once you have verified it truly stops when it should.
Another point: since a wrapper change always ripples to every project, I enumerate the affected projects in advance whenever I change a default policy value. The "surface all at once as type errors" mechanism is convenient, but it is safe to use only paired with the state of "knowing who is affected before the change." The strength of centralization is inseparable from clear visibility.
Where to start
Rewriting already-scattered calls all at once is hard. I unified new calls through the wrapper first and migrated existing ones whenever I had a reason to touch them. Rather than doing it all in one go, mechanically detecting the spots that import the SDK directly and whittling the list down little by little was the realistic path.
After collecting safeguards scattered across five projects into one wrapper, policy-update misses went to zero. Safeguards cannot be protected by faithfully copying them everywhere forever. Only by narrowing the path to one and collecting them there do they become protectable without relying on carefulness.
I hope this gives anyone worn down by copying the same safeguards across projects a reason to revisit their design.
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.