Antigravity Multi-Agent State Tiers — A Three-Layer Design with Ephemeral, Journal, and Canonical
Before your Antigravity Background Agents and Sub-agents start mixing up their memory, split agent state into three lifetimes — ephemeral, journal, canonical — and map each to the right Cloudflare store. Includes a TypeScript approval gate for write-back, and what changed when headless runs stopped silently auto-approving.
One morning, an Antigravity Background Agent forgot the previous day's AdMob review and emailed me the same warning three times in a row. Following the run logs, the memory was technically present, but the restore key was off by a few characters and the agent could not read it back. I build iOS and Android apps solo, and I run most of the surrounding operations through editor-integrated AI agents like Antigravity. After a few rounds of "the agent forgot too much" and "the agent remembered too much," I landed on a single rule: agent state has to be split into three lifetimes, or it will eventually break. Looking back, what was broken was never the memory — it was my habit of putting information with different lifespans on the same shelf.
This piece is a practical write-up of those three lifetimes — ephemeral, journal, canonical — together with how I map them onto Cloudflare Workers stores (KV, Durable Objects, R2, D1), and how I gate the write-back boundary with an explicit approval step. If you are running more than one Antigravity agent in production, the boundaries below should give you a frame to sharpen your own design decisions.
The same Antigravity agent holds information with three very different lifespans
When you call everything "agent memory," it eventually breaks. The split I have settled on is:
Tier
Lifetime
Damage if lost
Examples
ephemeral
1 run
near zero (recomputable)
intermediate prompts, raw LLM output, file snapshots
published slugs, purchase records, membership state
The deciding factor is not byte size but blast radius. A 200KB chain-of-thought can be ephemeral. A 60-byte "we already published this slug" record absolutely cannot be ephemeral.
When you internalize the split, agent code only needs to ask three questions:
Can the next run recompute this? If yes — ephemeral.
If not, can a human accept "we have to redo it"? If yes — journal.
If losing it breaks the user-facing product — canonical.
That is the entire rubric. The tiers map to different physical stores, but agent code talks to all three through a single adapter shape.
Ephemeral — working memory that lives and dies inside one agent run
Ephemeral is everything that is born when an agent run starts and dies when it ends: scratch prompts, raw LLM responses, intermediate file listings, retrieval results, in-flight reasoning. Because the next run can rebuild it cheaply, there is almost no value in writing it to a shared store like KV.
In my Antigravity Background Agents this tier lives in plain JavaScript memory — a Map scoped to the run entry function.
// state/ephemeral.tsexport class EphemeralStore { private bucket = new Map<string, unknown>(); get<T = unknown>(key: string): T | undefined { return this.bucket.get(key) as T | undefined; } put<T = unknown>(key: string, value: T): void { this.bucket.set(key, value); } // Always call this at the end of a run. Disposal is explicit. dispose(): void { this.bucket.clear(); }}
The explicit dispose() is intentional. I want ephemeral to be actively thrown away, not silently abandoned. If you let an intermediate LLM response leak into the journal or canonical tier by accident, your storage bill rises without bound. A single Background Agent in my fleet handles roughly 200–800 KB of ephemeral data per run; written naively into KV that adds up to gigabytes per month, far above the ¥800/month budget I keep per site.
One implementation gotcha worth flagging: Cloudflare Workers can reuse the same Isolate across requests, so a Map declared at the module scope will quietly persist between runs. I fell into that exact trap once. The fix was to make sure EphemeralStore is always new-ed inside the run entry function.
Because that reuse comes and goes under wrangler dev, I would rather reproduce it on purpose in a test than hope to notice it in production. Mine is deliberately small: hit the same Worker twice and check that nothing leaked from the first request into the second.
// test/ephemeral-leak.test.ts — @cloudflare/vitest-pool-workersimport { SELF } from "cloudflare:test";import { expect, it } from "vitest";it("ephemeral does not survive across runs", async () => { // /run?probe=1 writes one entry, then returns the Map size it can see const first = await SELF.fetch("https://example.com/run?probe=1"); const second = await SELF.fetch("https://example.com/run?probe=1"); const a = await first.json<{ size: number }>(); const b = await second.json<{ size: number }>(); // each run should only see the single entry it wrote itself expect(a.size).toBe(1); expect(b.size).toBe(1);});
Both calls land in the same execution environment inside the test, so a Map that escaped to module scope shows up as size === 2 on the second request. The morning that test first went red was the morning I finally looked at where I was calling new. One failing test turned Isolate reuse from an occasional ghost into a reproducible fact.
✦
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
✦Split agent state into ephemeral / journal / canonical so a single Background Agent run can fail without taking your product down
✦Concrete Cloudflare mapping — KV / Durable Objects / R2 / D1 — with a small TypeScript adapter and a two-request vitest that catches ephemeral state leaking across Isolate reuse
✦A minimal TypeScript approval gate that holds canonical writes for review, plus a table for telling a held write apart from a headless exit code 3 or a --print-timeout
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.
Journal — days to weeks of decision logs that you could re-derive, but slowly
Journal is for information that you can technically reconstruct, but only at meaningful cost in time or money. Agent decision rationales, A/B intermediate scores, Browser Sub-agent observations of an AdMob dashboard delta, automatic-repair logs — all journal.
The test for journal is one question: "If we lost this, could we live without it but only at a half-day cost?" I have had multiple incidents where missing journal data meant burning a day or two replaying agent decisions. The painful variant is when the output survived but the reasoning did not — you can see what the agent produced, yet you cannot tell why it branched that way, so you walk into the same mistake again. I take that tier seriously now.
Cloudflare KV is a near-perfect physical match — cheap reads, metered writes, eventual consistency, all of which suit "write rarely, read sometimes, freshness is loose."
Two operational notes. First, always prefix journal keys with journal: even if you do not share KV with canonical today — your future self will thank you when the boundary moves. Second, always attach a TTL. Without one, agents start dragging months of context into their next decision and begin to behave erratically. I have made that mistake twice across this fleet and now hard-code 30 days for everything.
At a small scale — on the order of 100k writes and 1M reads a month — the journal tier fits inside the KV allowance that comes with the Workers paid plan. In my setup it has never become a line item worth worrying about inside the ¥800/month per-site envelope.
Canonical — the source of truth your product cannot survive without
Canonical is the information whose loss ends the product. Published article slugs, Stripe purchases, premium membership records, monthly AdMob revenue rollups (technically refetchable, but throttled by API limits) — canonical.
My personal rule is strict: the agent never owns canonical. Canonical lives in the surrounding system, and the agent only reads or writes back to it. The moment an agent thinks it owns canonical, an agent bug becomes a product-wide outage.
For physical mapping I lean on two choices depending on the shape of the data.
Shape
Store
Why
Row-oriented, transactional
Cloudflare D1 (or an upstream API like Stripe)
strong consistency + SQL for audits
Document-oriented, concurrent writes
Cloudflare Durable Objects
strong ordering, one instance per key
Large, read-heavy
Cloudflare R2
monthly snapshot archive
A concrete case: in Antigravity Lab, the canonical list of published slugs is the GitHub repo itself. The agent clones it, reads it, and writes back only via the GitHub API. The agent's KV and DO are journal-tier; once the change lands in Git, they can be thrown away.
Here is a minimal Durable Object adapter for the case of "concurrent purchase grants" — the kind of canonical write where two requests can hit the same key at the same time.
Keeping canonical outside the agent buys you something priceless: the freedom to throw the agent away and rebuild. An agent that owns canonical is one you are scared to redeploy. Working solo, I rebuild agents constantly, so that freedom is a structural asset rather than a nicety.
Mapping three tiers onto Cloudflare stores — KV / DO / R2 / D1
I decide the mapping with three axes:
Lifetime: 1 run / dozens of days / permanent
Concurrent writes: can multiple agents hit the same key at the same time?
Query shape: single-key read, prefix scan, or SQL/joins?
That collapses into a near-mechanical decision tree.
1 run, no concurrency, single-key reads → ephemeral (in-memory Map).
Early on I dumped everything into KV and accidentally crossed agent boundaries five or six times due to missing prefixes. It only dawned on me that the design was the problem while I was writing the same fix for the third time. Switching to the three-axis decision rule effectively eliminated state-tier bugs at design time. I recommend forcing yourself through these three questions every time you add a new state key.
Boundary design — where to write back, where to snapshot
Splitting the tiers is not enough; you also have to decide where information crosses between tiers. In my fleet there are three boundaries worth naming.
Step 1. ephemeral → journal (write-down)
Only the decisions worth replaying should land in journal — typically the moments an agent branched on a non-trivial choice. Writing every intermediate value bloats the journal; I keep the rule "log a journal entry only when the agent made a branching decision."
Step 2. journal → canonical (write-back)
This is the boundary you must design most carefully. I strongly recommend a human-in-the-loop checkpoint here, or at minimum an out-of-band notification (email or Slack). I have had an agent decide on its own to flip 12 premium articles back to free in a single batch, and the recovery took half a day. A 5-second confirmation delay or a single approval click would have caught it.
Step 3. canonical → journal (read-back snapshot)
The reverse flow is just as important. Periodically snapshotting canonical into journal keeps the agent from spamming canonical reads (and helps you stay under GitHub's 5,000 req/hour ceiling). An hourly snapshot is usually enough.
Once you spell these three boundaries out in code, the agent reads dramatically more clearly. Leave them implicit, and you will not be able to tell whether a given function is touching ephemeral or canonical without a 10-minute audit.
Implementing the approval gate — the smallest code that stops a write-back
"Put a human in the loop" is easy to say and easy to lose in implementation. The smallest version I use carries the tier in the type signature and drops canonical writes into a pending queue instead of applying them.
// state-gate.ts — one gate, three tiers, different write semanticstype Tier = "ephemeral" | "journal" | "canonical";interface PendingWrite { id: string; key: string; value: unknown; reason: string; // why the agent wanted to write createdAt: number;}interface GateDeps { journal: KVNamespace; canonical: DurableObjectStub; notify: (w: PendingWrite) => Promise<void>;}const JOURNAL_TTL = 60 * 60 * 24 * 30; // 30 daysexport async function write( tier: Tier, key: string, value: unknown, reason: string, deps: GateDeps,): Promise<{ applied: boolean; pendingId?: string }> { if (tier === "ephemeral") { // caller keeps it in a Map; nothing is persisted return { applied: true }; } if (tier === "journal") { await deps.journal.put(key, JSON.stringify({ value, reason }), { expirationTtl: JOURNAL_TTL, }); return { applied: true }; } // canonical never applies inline const pending: PendingWrite = { id: crypto.randomUUID(), key, value, reason, createdAt: Date.now(), }; await deps.journal.put(`pending:${pending.id}`, JSON.stringify(pending), { expirationTtl: 60 * 60 * 24 * 3, // expires in 3 days if nobody approves }); await deps.notify(pending); return { applied: false, pendingId: pending.id };}export async function approve(id: string, deps: GateDeps): Promise<boolean> { const raw = await deps.journal.get(`pending:${id}`); if (!raw) return false; // expired — a deliberate rejection, not a dropped write const p = JSON.parse(raw) as PendingWrite; await deps.canonical.fetch("https://do/put", { method: "POST", body: JSON.stringify({ key: p.key, value: p.value }), }); await deps.journal.delete(`pending:${id}`); return true;}
The return type of write() is the part that matters. Because there is a path that returns applied: false, the calling agent cannot proceed as if the write landed. Make the function return void instead, and the agent has no way to distinguish "pending" from "applied" — it will happily treat a stale value as canonical.
The three-day expiry on pending: is also deliberate. Let approvals pile up indefinitely and you end up approving a change a month later with no memory of what it was for. A forgotten change is safer left unapplied.
Tier
Write
Approval
Expiry
ephemeral
immediate (memory)
none
end of run
journal
immediate (KV)
none
30 days
canonical
held
required
3 days pending
Headless runs used to auto-approve silently — a premise that changed
An approval gate is worthless if the execution layer approves on your behalf. This is a case where the ground actually shifted, so it is worth recording.
Antigravity CLI 1.1.3 (2026-07-16) shipped two fixes around headless execution (-p). The first: tools requiring confirmation would either hang the process or get silently auto-approved. After the fix, they soft-reject and print the allow rule name needed for permission to stderr. The second: in always-proceed mode, file writes outside the workspace were being auto-approved by mistake.
If you run tasks unattended overnight, both fixes change your assumptions. On anything older than 1.1.3, a flow you believed was gated may have been passing straight through.
Here is how I verified mine:
Check the CLI version with antigravity --version
If it is below 1.1.3, update. If you cannot, stop canonical writes from headless runs until you can
After updating, run one operation that should require approval under -p and watch for the allow rule name on stderr
If nothing appears, that tool is already permitted by an allow rule — go audit the rule list
Step three is the one people skip. Without confirming that a soft rejection actually happens, you are running on the assumption that "the fix landed, so we must be fine." That is where I discovered my own allow rules were written far more broadly than I intended.
One related thing worth checking: a token accounting bug that made conversations hit their limit earlier than expected was fixed in the 2026-07-21 update. If you orchestrate long agent runs, re-measure your consumption afterward. Otherwise it is easy to confuse "stopped because of the limit" with "held by the approval gate."
Later CLI releases made that confusion harder to fall into. CLI 1.2.6 removed the five-minute default timeout on headless -p runs and, on failure, now prints a single structured AGY_ERROR: {...} line to stderr and exits with code 3. CLI 1.2.9 stops the daemon that used to linger after -p finished, and waits for background work up to --print-timeout (capped at 30 minutes).
I rewrote my nightly pass/fail rule around that: exit code 0, and no AGY_ERROR on stderr. A run in which the gate returned applied: false is, from the agent's side, a clean exit — the only trace is the pending notification. Keeping that out of the "failed" bucket is what lets the morning check take five minutes instead of thirty.
What I see
What it means
What I do
exit 0, pending notification present
the gate held the write as designed
approve in the morning, or let it expire in 3 days
exit 3, AGY_ERROR on stderr
the CLI treated the run as an error
do not approve the pending write; read the cause, rerun
exit 0, no pending notification
canonical was never touched, or an allow rule let it through
audit the allow rules
Approval waits outside the run; the run itself never waits. When the gate holds a write, the run ends right there, and a different process — me, the next morning — does the approving. Even with --print-timeout stretched to 30 minutes, I refuse to park approval inside it, because a timeout and a hold should never share a log line. The input side of the same problem — rejecting malformed commands before they reach the agent — is in Guarding the Turn Boundary in Non-Interactive Runs: A Command Table and a Fail-Closed Gate.
Three operational rules I refuse to break
A few rules I hold to, drawn from keeping agents running day after day.
Always put either a 1-second delay or an approval step in front of canonical writes. Recovering from a runaway canonical write averages half a day, sometimes two. A 5-second confirmation buffer is free. My AdMob revenue rollup agent uses that exact pattern.
When in doubt, give journal a 30-day TTL. Extending later is trivial; capping an untimed journal after the fact is the storage equivalent of memory loss. Every journal scope in my Background Agents is locked to 30 days.
Never silently drop ephemeral. Provide an explicit dispose() and call it. That single rule wiped out my Isolate-reuse and memory-leak issues. After one painful incident where a Browser Sub-agent ballooned in memory for three days and crashed, I refuse to ship an ephemeral store without it.
All three are about pre-committing to where agent failures are allowed to live — and that is the underlying claim of this whole piece. Agents will misbehave; the design job is to keep their failures inside the smallest tier possible.
The next time you stand up an Antigravity Background Agent, write a one-line comment at the top: "this agent touches ephemeral / journal / canonical." Half of your design review is already done. Keeping failures inside the smallest tier is the one rule I hold onto even on the nights I am in a hurry.
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.