The Flaky Hook Started Failing Every Time — Migrating to Declarative Sessions in the Antigravity SDK
Registering hooks after session init is gone, replaced by declaration at session creation. Here is what happened when I moved four agent definitions across, plus the harness I used to measure ordering non-determinism over 2,000 runs.
The morning after the migration, CI was stuck on red.
The day before, that same test suite passed maybe four runs out of ten. Now it never passed. The only thing I had touched was how hooks were registered.
Here is the conclusion up front: that red board was not a failed migration. It was proof the migration had worked.
What changed is not what you can register, but when it is decided
In the Antigravity SDK, registering hooks and triggers dynamically after session initialization has been removed in favor of declaring them at session creation. Details shift between versions, so check the current wording in the Antigravity changelog before you plan your own move.
What you can register has not changed. What changed is the moment your configuration becomes final.
Aspect
Dynamic registration after init
Declaration at session creation
When config is finalized
At runtime, as registrations complete
The instant the session is created
Hook execution order
Depends on which registration finished first
The order of the array you wrote
Where conditionals live
In whether you register at all
Inside the hook body
Reproducibility for identical input
Can vary run to run
Always identical
Inspecting the configuration
Requires running it
Readable statically, before launch
I thought I was migrating for the last row. Being able to read a configuration without executing it is the obvious win. The row that actually mattered turned out to be the one above it.
Find every call site mechanically
My first instinct was grep -rn "register_hook". I only maintain four agent definitions for my own apps, so eyeballing the results felt reasonable.
It was not enough. A register_hook sitting inside an if, inside a loop, or inside a try demands completely different rewrites. Grep hands you lines. It does not hand you the context those lines sit in.
So I wrote a small script that reports both the call site and its syntactic context.
#!/usr/bin/env python3"""Find post-init hook/trigger registrations and sort them by migration difficulty."""import ast, os, sysDYNAMIC = {"register_hook", "register_trigger", "add_hook", "add_trigger"}def scan(root): rows = [] for dp, _, fs in os.walk(root): if any(seg in dp for seg in (".venv", "node_modules", "__pycache__")): continue for f in fs: if not f.endswith(".py"): continue p = os.path.join(dp, f) try: tree = ast.parse(open(p, encoding="utf-8").read(), p) except SyntaxError as e: # Half-migrated files will land here. Never swallow this silently. print(f"skip (syntax): {p}: {e}", file=sys.stderr) continue # ast nodes carry no parent pointer, so build the child -> parent map first parent = {} for n in ast.walk(tree): for c in ast.iter_child_nodes(n): parent[c] = n for n in ast.walk(tree): if not (isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)): continue if n.func.attr not in DYNAMIC: continue ctx, cur = set(), parent.get(n) while cur is not None: if isinstance(cur, ast.If): ctx.add("conditional") elif isinstance(cur, (ast.For, ast.While, ast.comprehension)): ctx.add("loop") elif isinstance(cur, (ast.Try, ast.ExceptHandler)): ctx.add("try") cur = parent.get(cur) rows.append((p, n.lineno, n.func.attr, sorted(ctx))) # ast.walk is breadth-first, so results are not in source order. Re-sort here. return sorted(rows, key=lambda r: (r[0], r[1]))TIER = { frozenset(): "A: move straight into an array", frozenset({"conditional"}): "B: rewrite as an early return inside the hook", frozenset({"loop"}): "B: expand generated functions into the array", frozenset({"try"}): "C: decide failure behavior first",}def main(root): rows = scan(root) for p, line, call, ctx in rows: tier = TIER.get(frozenset(ctx), "C: decide by hand") print(f"{p}:{line} {call:<17} ctx={','.join(ctx) or '-':<12} {tier}") print(f"\n{len(rows)} call sites total")if __name__ == "__main__": main(sys.argv[1] if len(sys.argv) > 1 else ".")
Because ast.walk traverses breadth-first, printing results as they come out scrambles the file order. Migration work goes top-to-bottom through a file, so skipping that re-sort costs you rework. That tripped me up on the first pass.
Running it against my definitions:
sample/agents/build_agent.py:4 register_hook ctx=- A: move straight into an array
sample/agents/build_agent.py:6 register_hook ctx=conditional B: rewrite as an early return inside the hook
sample/agents/build_agent.py:7 register_trigger ctx=- A: move straight into an array
sample/agents/review_agent.py:5 register_hook ctx=loop B: expand generated functions into the array
sample/agents/review_agent.py:6 register_trigger ctx=- A: move straight into an array
5 call sites total
Three A's, two B's. Small numbers, but the split is what lets you estimate how far you will get in a sitting. The one tier that needs thought before any code moves is C — a registration inside a try forces you to answer whether a failed registration should halt startup or let it continue.
✦
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
✦An AST-based script that finds every post-init register_hook / register_trigger call and sorts them into difficulty tiers A, B, and C — complete and ready to run against your own repository
✦A measured result showing that dynamic registration satisfied hook ordering dependencies only 48.2% of the time across 2,000 runs, along with the minimal harness that produced it
✦The reversal nobody warns you about: declaring hooks does not remove the bug, it makes the bug reproduce every time — and how to read a CI board that turns solid red right after the migration
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.
Registrations with no condition and no loop just move.
# Before: create the session, then bolt things onto itdef build(session): session.register_hook("pre_tool_use", audit_hook) session.register_trigger("on_file_write", reindex) return session
# After: hand everything over at creation timefrom google.antigravity import AgentConfigconfig = AgentConfig( hooks={"pre_tool_use": [audit_hook]}, triggers={"on_file_write": [reindex]},)
The diff looks unremarkable. What it buys is that from this point on, reading the configuration no longer requires running it.
Tier B moves the condition from "register?" to "execute?"
I took a detour here worth describing, because it wasted an afternoon.
My first attempt pushed the conditions into the declaration: one AgentConfig per flag combination, selected at startup. I stopped a few lines in. Three flags means eight configurations, and the count doubles with every flag added. I could not see myself maintaining that.
The correct direction was the opposite. Declare everything statically, and decide whether to act inside the hook.
# Before: the condition lives in whether we registerif feature_flags.get("strict_diff"): session.register_hook("post_tool_use", diff_guard)
# After: the condition moves inside. The declaration is always the same shape.def diff_guard(event, ctx): if not ctx.flags.get("strict_diff"): return # when disabled, it exists as a hook that does nothing ...config = AgentConfig(hooks={"post_tool_use": [diff_guard]})
Declarations are static; judgments are runtime. Once I settled that line, the rest of the rewrites stopped requiring decisions.
Loops fold the same way — extract the factory, expand into the array.
# Beforefor name in ("lint", "typecheck", "test"): session.register_hook("post_tool_use", make_gate(name))
# After (the ordering now becomes visible on the page)GATES = ["lint", "typecheck", "test"]config = AgentConfig( hooks={"post_tool_use": [make_gate(n) for n in GATES]},)
I hesitated over whether a comprehension belongs inside a declaration. I have concluded that it does. It evaluates exactly once, at creation, so the guarantee that configuration cannot change at runtime still holds.
Nobody had ever decided the order
This is where the migration actually consumed my time.
While registration was scattered across asynchronous init paths, hook execution order was simply "whichever registration finished first." Slip a config file read or an MCP server connection into one of those paths and the order shifts from run to run.
I had no intuition for how much it shifted, so I measured it. Not the SDK itself — a minimal reproduction of how each scheme decides ordering.
import asyncio, random, collections, timeHOOKS = ["audit", "diff_guard", "reindex", "metrics", "cost_cap"]# Scheme A: dynamic registration, each setup path doing async I/Oasync def dynamic_register(sink, name): await asyncio.sleep(random.uniform(0.0, 0.002)) # config reads, MCP connects sink.append(name)async def run_dynamic(): sink = [] await asyncio.gather(*(dynamic_register(sink, h) for h in HOOKS)) return tuple(sink)# Scheme B: declared at creation, array order is execution orderdef run_declarative(): return tuple(HOOKS)N = 500dyn = collections.Counter(asyncio.run(run_dynamic()) for _ in range(N))dec = collections.Counter(run_declarative() for _ in range(N))print(f"dynamic: distinct orders observed = {len(dyn)}")for o, c in dyn.most_common(3): print(f" {c/N*100:5.1f}% {' -> '.join(o)}")print(f"declarative: distinct orders observed = {len(dec)}")
Five hooks have 120 possible orderings. In 500 runs, 119 of them showed up. The most frequent single ordering appeared 1.8% of the time. No ordering was meaningfully favored.
On its own that is just "ordering varies." It becomes a defect the moment hooks depend on each other. In my setup cost_cap finalizes the spend ceiling and diff_guard reads it to decide. Reverse them and the decision reads an unfinalized value.
async def dynamic_once(): sink = [] await asyncio.gather(*(reg(sink, h) for h in ["audit", "diff_guard", "reindex", "metrics", "cost_cap"])) return sink.index("cost_cap") < sink.index("diff_guard") # True = dependency heldN = 2000ok = sum(asyncio.run(dynamic_once()) for _ in range(N))print(f"dynamic, {N} runs: dependency satisfied {ok/N*100:.1f}% of the time")
dynamic, 2000 runs: dependency satisfied 48.2% of the time (violated 51.8%)
declarative: dependency satisfied 100.0% of the time
48.2% over 2,000 runs. Essentially a coin flip.
"Passes about four times out of ten" lines up with that number well. The test was not failing because of the environment, and not because of some subtle timing skew. It was failing because nobody had ever decided the order.
"Fails every time" means the bug is finally visible
Back to that red CI board.
Once declared, execution order follows the array I wrote. And the array I first wrote happened to be the losing side — diff_guard before cost_cap. I had not reconstructed the previous ordering; I had simply listed them in the order they appeared in the old code.
My intuition said that pinning the order would make the flaky test settle into passing. The opposite happened. It settled into failing.
Declarative configuration does not remove defects. It makes them reproducible.
That distinction matters more than it sounds. A bug that fails 48.2% of the time cannot be verified as fixed — two green runs might be luck. A bug that fails 100% of the time is fixed the first time it passes. In terms of pulling failures reliably onto your own machine, this sits alongside the approach in Record & Replay for Antigravity Agents — A Production Pattern to Reproduce Failures in 3 Minutes.
If your CI goes solid red right after this migration, suspect the declared order before you suspect the migration. I lost half a day there.
Three rules I used to decide the order
Rather than arranging the array by feel and repeating the same mistake later, I wrote down rules first.
Position
What goes there
Why
First
Ceilings and budgets (cost_cap and friends)
Values that later checks read must be final before those checks run
Middle
Recording and auditing (audit, metrics)
Rejected operations should still leave a record
Last
Rejection and abort decisions (diff_guard)
By the time you stop here, everything upstream has been gathered
Written out, it reads as obvious. But under dynamic registration there was no place in the codebase where that ordering could be written down at all. Having somewhere to write it may be the real substance of this change.
from google.antigravity import AgentConfigHOOK_ORDER = [cost_cap, audit, metrics, diff_guard] # the order carries meaningconfig = AgentConfig( hooks={"post_tool_use": HOOK_ORDER}, triggers={"on_session_end": [flush_metrics]},)
Two things bit me during the move that no amount of reading would have prevented.
The first is the scanner quietly under-reporting. Re-run it partway through the migration and half-edited files raise SyntaxError. Swallow that with a bare except SyntaxError: continue and your remaining-work count drops to zero for the wrong reason. I convinced myself I was finished once. One line to stderr avoids it, and I would treat that line as mandatory.
The second is the runtime cost of hooks that are permanently resident but disabled. Once conditions live inside the hook, disabled hooks still get invoked on every event. As an indie developer running these agents unattended overnight, I wanted a number rather than a feeling, so I measured it:
disabled hook: 0.168 microseconds per invocation (1,000,000 iterations)
200 post_tool_use events x 3 disabled hooks per session: 100.8 microseconds total
Roughly 0.1 ms per session. I decided that was safe to ignore. The caveat is that this only holds if the early return comes before any I/O — put a config read above it and the order of magnitude changes. That is a rule worth fixing before you measure anything.
Migration checklist
Run scan.py across every directory and get the A/B/C counts before touching code
For any C (registration inside try), decide up front whether failure halts startup
Move the A's into arrays — this part is mechanical
Push B conditions into early returns inside hooks, not into the declaration
Extract B loops into factories and expand them with a comprehension
Decide the ordering deliberately — ceilings, then records, then judgments
If a test starts failing after the move, suspect the array order first
Wire scan.py into CI so dynamic registration cannot creep back in
Items 7 and 8 were added after being bitten. Item 8 in particular came from an older branch merging the previous style back in once the migration was already "done."
What got worse
To be fair about the trade: declaring everything made one thing more awkward.
Swapping a single hook for a test used to be one extra registration line. Now I build the configuration through a small factory and let tests substitute the array.
def build_config(hooks=None): return AgentConfig(hooks={"post_tool_use": hooks or HOOK_ORDER})
That solved it, but it is undeniably one more moving part. In exchange I get a guarantee that configuration cannot drift at runtime, and I am comfortable with that trade.
Ordering non-determinism stays invisible for as long as things happen to work. Until I saw 48.2%, I was convinced the environment was to blame. If you have a hook that fails "sometimes," run the harness above against your own setup and find out what your number actually is.
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.