When Antigravity Swaps Its Default Model, Only the Jobs You Narrowed First Survive
Gemini 3.7 Flash is now the default model for Antigravity agents. The places you never configured are exactly the places that shift silently. Here is how to inventory your default-model exposure, then split your jobs into move-now and hold, scored by how much output freedom you left open.
There is a generation job I run first thing every morning. It classifies wallpaper assets for one of the apps I maintain, and it has been running on the same configuration for months.
I did not notice that its output could change by looking at my own environment. I noticed it in a changelog. Gemini 3.7 Flash, generally available since August 13, is now the default model for Antigravity agents.
I had not touched a single line of configuration. Every place where I never wrote a model name moved to the new default anyway.
Before deciding whether to migrate, there was something else to establish: which parts of my setup were sitting on a thing called "default" — a place with no name of its own.
A default model changes without waiting for your consent
Switching models is normally something you initiate. You evaluate, you compare, you move if it looks better.
A default swap runs in the opposite direction. The provider decides, and the change lands wherever your configuration is silent. Nobody asks you, so you have no memory of agreeing.
Gemini 3.7 Flash ships at an introductory price of $0.75 per million input tokens and $3.75 per million output tokens. That price holds through December 31, 2026. From January 1, 2027 it becomes $1.50 and $7.50. The context window is 1,048,576 tokens with a 65,536-token output ceiling.
Numbers like that pull you toward a cost conversation. I deliberately did not start there, for reasons I will get to.
The first move is an inventory.
Find every place that rides on the default
Reading configuration files with your eyes is less reliable than it feels. Model selection lives in at least three places — agent definition frontmatter, settings.json entries, and CLI invocations in shell scripts — and in all three, "I forgot to specify this" looks almost identical to "I deliberately delegated this."
If you can't separate those two, the inventory is worthless. A file that says inherit is a place where someone understood the default would move and chose to follow it. A file with no model key at all is a place nobody thought about. Only the second kind needs attention.
#!/usr/bin/env python3"""Find every place that silently depends on the default model.Scans: 1. Agent definition YAML frontmatter (presence of a model key) 2. settings.json agents.* entries 3. Shell scripts invoking the CLI (presence of --model)"inherit" and "default" count as explicit delegation, kept separate froma missing key. Collapsing the two makes the inventory useless."""import jsonimport pathlibimport reimport sysDELEGATING = {"inherit", "default", "auto"}def read_frontmatter(path: pathlib.Path) -> dict: """Read only the leading --- block, without depending on PyYAML.""" text = path.read_text(encoding="utf-8") if not text.startswith("---"): return {} end = text.find("\n---", 3) if end == -1: return {} fm = {} for line in text[3:end].splitlines(): if ":" not in line or line.strip().startswith("#"): continue key, _, value = line.partition(":") fm[key.strip()] = value.strip().strip("\"'") return fmdef scan(root: pathlib.Path) -> list: findings = [] for path in sorted(root.glob(".antigravity/agents/*.md")): fm = read_frontmatter(path) model = fm.get("model") if model is None: state = "unpinned" # no key at all: riding the default silently elif model.lower() in DELEGATING: state = "delegated" # following the default on purpose else: state = "pinned" findings.append({"where": str(path.relative_to(root)), "kind": "agent-def", "name": fm.get("name", path.stem), "state": state, "model": model}) settings = root / ".antigravity" / "settings.json" if settings.exists(): data = json.loads(settings.read_text(encoding="utf-8")) for name, cfg in (data.get("agents") or {}).items(): model = cfg.get("model") state = "unpinned" if model is None else ( "delegated" if str(model).lower() in DELEGATING else "pinned") findings.append({"where": "settings.json", "kind": "settings", "name": name, "state": state, "model": model}) for path in sorted(root.rglob("*.sh")): for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): if "antigravity" not in line or " run" not in line: continue pinned = re.search(r"--model[= ]([\w.\-]+)", line) agent = re.search(r"--agent[= ]([\w.\-]+)", line) findings.append({"where": f"{path.relative_to(root)}:{lineno}", "kind": "cli-call", "name": agent.group(1) if agent else "(inline)", "state": "pinned" if pinned else "unpinned", "model": pinned.group(1) if pinned else None}) return findingsdef main() -> int: root = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve() findings = scan(root) unpinned = [f for f in findings if f["state"] == "unpinned"] width = max((len(f["where"]) for f in findings), default=10) for f in findings: mark = {"pinned": " ", "delegated": "= ", "unpinned": "! "}[f["state"]] print(f"{mark}{f['where']:<{width}} {f['kind']:<9} {f['name']:<20} {f['model'] or '-'}") print(f"\n{len(unpinned)} of {len(findings)} locations depend on the default (marked !)") return 1 if unpinned else 0if __name__ == "__main__": sys.exit(main())
Run against a small sample tree, it produces this:
Line six is the one worth staring at. classify-wallpaper has a model in its definition and a model in settings.json. The nightly script still calls it without --model.
Two out of three places were filled in, which is exactly why it felt finished. Which layer actually wins at runtime can vary by version and invocation path — so it is safer to assume every path is a hole until you have watched one run.
The script exits non-zero when anything is unpinned, which makes it usable as a gate in front of a scheduled run. New agents get caught the day they are added.
✦
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
✦You will be able to mechanically inventory every place in your setup that silently rides on the default model, across agent definitions, settings, and shell scripts
✦You will be able to freeze the jobs that would break before the format drift reaches you, instead of chasing malformed output after the fact
✦You will be able to decide whether to adopt a new model from the shape of your output contract rather than from a promotional price with an expiry date
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 split is decided by output freedom, not model quality
With the inventory done, the next question is which jobs to move onto the new default.
Most people reach for a benchmark table here. I did too, at first. But when I thought back to the times a model change actually cost me something, quality was never the issue.
Among the jobs I run as an indie developer, some survive a model swap untouched and some need hand-fixing every time. Wallpaper category classification is the first kind. The agent can only return one of thirty identifiers, and a downstream validator rejects anything else. Classification quality may drift a little; the shape of the output cannot.
Generating multilingual store release notes is the second kind. What comes back is prose. Change the model and the heading style shifts, the bullet granularity shifts. Nothing is broken — something merely different arrives. And when something different arrives, the formatting step downstream stops fitting.
So resilience to a model swap is not a property of using a good model. It is a property of how much of the output space you closed off in advance.
Scoring that freedom from the schema is steadier than eyeballing it.
#!/usr/bin/env python3"""Score how much freedom an output contract leaves open.More freedom means more breakage when the default model changes.Breakage is not a function of model quality. It is a function of howmuch of the output space you closed off in advance.Score: 0 (fully closed) to 100 (fully free-form)."""import jsonimport pathlibimport sys# Points added per unit of allowed freedom. Strings can be closed with an# enum, so only unclosed strings are penalized.WEIGHTS = {"open_string": 25, "open_array": 15, "no_schema": 100, "additional_props": 20, "optional_field": 5}def score_schema(schema: dict): reasons = [] points = 0 props = schema.get("properties") or {} required = set(schema.get("required") or []) if schema.get("additionalProperties") is not False: points += WEIGHTS["additional_props"] reasons.append("additionalProperties is not closed") for name, spec in props.items(): kind = spec.get("type") if kind == "string" and "enum" not in spec and "pattern" not in spec: points += WEIGHTS["open_string"] reasons.append(f"{name}: free string with no enum or pattern") elif kind == "array": item = spec.get("items") or {} if "enum" not in item and item.get("type") == "string": points += WEIGHTS["open_array"] reasons.append(f"{name}: array with unclosed items") if name not in required: points += WEIGHTS["optional_field"] reasons.append(f"{name}: not required (presence may vary)") return min(points, 100), reasonsdef verdict(score: int) -> str: if score <= 20: return "move now (a swap shows up in validation)" if score <= 55: return "conditional (move it if a validator sits downstream)" return "hold (narrow the contract before moving)"def main() -> int: worst = 0 for path in sorted(pathlib.Path(sys.argv[1]).glob("*.json")): schema = json.loads(path.read_text(encoding="utf-8")) score, reasons = score_schema(schema) worst = max(worst, score) print(f"{path.name} freedom {score:>3}/100 -> {verdict(score)}") for r in reasons: print(f" - {r}") print(f"\nHighest freedom contract: {worst}/100") return 0if __name__ == "__main__": sys.exit(main())
Scoring the classification schema next to the release-note schema:
category.json freedom 5/100 -> move now (a swap shows up in validation) - confidence: not required (presence may vary)release_note.json freedom 100/100 -> hold (narrow the contract before moving) - additionalProperties is not closed - title: free string with no enum or pattern - title: not required (presence may vary) - body: free string with no enum or pattern - body: not required (presence may vary) - highlights: array with unclosed items - highlights: not required (presence may vary)Highest freedom contract: 100/100
The absolute numbers mean nothing on their own — tune the weights to your own workload. What matters is the ordering. If you can hold the 100 and move the 5, the tool has done its job.
Do not read the 100 as a bad schema. Release notes are supposed to be prose. A correctly free job is correctly fragile under model changes. That is all it says.
You cannot verify a pin by re-reading your config
For the jobs marked hold, you pin the model. This is where I tripped.
You add the model name, you open the file again to confirm you added it, and you consider it handled. The flaw in that check is that you are confirming what you wrote, not what ran.
There is more than one invocation path: the IDE, the CLI, the SDK, and delegation through a subagent. Which layer wins can change between versions, and whether a subagent inherits the parent's selection depends on how you assembled it.
The reliable check is to look at what came out.
#!/usr/bin/env python3"""Reconcile the model that actually ran against the one you declared.A pin in configuration can still miss a path, because the IDE, CLI, SDKand subagent routes resolve selection differently. Reading the configagain proves nothing; reading the run output does.Key names in the usage object drift between versions, so this searches aset of known candidates depth-first. Not finding one is itself a result."""import jsonimport pathlibimport sysMODEL_KEYS = ("model", "model_name", "model_version", "modelId")def find_model(node, path="$"): if isinstance(node, dict): for key in MODEL_KEYS: value = node.get(key) if isinstance(value, str): return value, path + "." + key for key, value in node.items(): found, where = find_model(value, path + "." + key) if found: return found, where elif isinstance(node, list): for i, value in enumerate(node): found, where = find_model(value, path + "[" + str(i) + "]") if found: return found, where return None, Nonedef main() -> int: expected = sys.argv[1] failures = 0 for path in sorted(pathlib.Path(sys.argv[2]).glob("*.json")): payload = json.loads(path.read_text(encoding="utf-8")) actual, where = find_model(payload) if actual is None: print("? " + path.name + " no model name in output (check this path)") failures += 1 elif actual == expected: print(" " + path.name + " " + actual + " (" + where + ")") else: print("! " + path.name + " expected " + expected + " / got " + actual + " (" + where + ")") failures += 1 print("\n" + str(failures) + " runs fall outside the pin") return 1 if failures else 0if __name__ == "__main__": sys.exit(main())
Fed three runs saved with --output-format json:
classify.json gemini-3.7-flash ($.model)! release-note.json expected gemini-3.7-flash / got gemini-3.5-flash ($.meta.runtime.model_name)? subagent-translate.json no model name in output (check this path)2 runs fall outside the pin
The third line is the interesting one. The subagent run carries subagent_info with a conversation_id and a log_uri, but no model name anywhere in the payload.
That is not a failure of the check. It is the check telling you where your visibility ends. Following log_uri may well answer it — and knowing that an extra hop is required is worth more than a green checkmark would have been. Had I treated "no model name found" as a pass, that whole path would have stayed invisible.
The multi-candidate key search exists for the same reason. Bet on a single key name and a version bump silently converts "key renamed" into "nothing wrong here." Absence always counts as a result.
One note for running this in production: put the reconciliation after your scheduled jobs, not before. Placed in front, it runs with no results on disk yet and stops on "no model name in output" every single time. I now sweep the day's JSON in one pass once the nightly jobs finish. Not exactly a trap, but it cost me a day of useless alerts before I moved it.
The Gemini 3.7 Flash introductory rate runs through December 31, 2026. The day after, input and output both double.
Period
Input (per 1M tokens)
Output (per 1M tokens)
Through December 31, 2026 (introductory)
$0.75
$3.75
From January 1, 2027
$1.50
$7.50
"Move while it's cheap" does not survive that table. If the destination doubles in under six months, a price-based migration loses its justification in January — and a migration whose justification evaporated is a migration you get to revisit.
Price is a legitimate input only when it is stable, or at least stable for longer than the horizon you are planning against. A promotional rate with a fixed end date is better read as a discount on evaluation than as a reason to commit.
That is how I used it. Even the jobs I decided to hold get run once against the new default during this window, with only the diffs reviewed. Observe without deciding. When the January price change arrives, the decision is made from evidence already sitting on disk.
After the inventory and the scoring, my jobs fell into three buckets.
Job
Output shape
Decision
Why
Wallpaper asset classification
One of 30 identifiers
Move
Closed value set; a validator rejects anything outside it
Crash cluster summaries
Prose, read by a human
Move
Nothing downstream depends on the shape
Multilingual store release notes
Prose, formatted downstream
Hold
The formatter depends on the shape; drift cascades
The second row surprised me. I had filed it under hold, on the logic that prose is fragile. But the reason to hold was never "this output is free-form" — it was "something downstream depends on its form." Output that a human reads and then discards can change shape without costing anyone anything.
The real axis turned out to be who absorbs the freedom. If a machine absorbs it, hold. If a person absorbs it, move. Once that came into focus, the sorting took minutes rather than an afternoon.
The third row ships to both the App Store and Google Play, and each locale has its own length limits and line-break conventions. As long as a machine performs that formatting, a shift in phrasing style breaks it. Hence the hold. The second row has identical output freedom and the opposite decision, purely because a person absorbs the variance.
Jobs that are free-form yet demand strict key structure downstream — localized string resources being the obvious case — land in the middle. Those need their contract narrowed before they can move, and narrowing is where the time actually goes. In that case I would recommend rewriting the contract before touching the model. Do it the other way around and you end up designing a contract while staring at broken output, which clouds the judgment you were trying to make.
What to do next
Run default_model_exposure.py at the root of your own repository once. Even the raw count changes what you are deciding about.
For a long time I read "default" as a synonym for "stable." It is closer to a synonym for "the place I never decided." Once you can count how many of those you have, the next time a default moves is a day you verify rather than a day you get surprised.
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.