I Made Antigravity Switch Its Own Brain

Git commit messages don’t need Claude Opus. Neither do file renames, linting, or dependency updates. But Antigravity, Google’s agent-first IDE, was burning premium quota on all of them because it uses the same model for everything.

So I made it stop.

Antigravity runs on Electron. Electron is Chromium. And Chromium ships with a debug protocol called CDP that lets you puppet the entire browser over a WebSocket. Every button, every dropdown, every UI element. Including the model selector.

One config line. One restart. And now a script can read which model is active, switch to a cheaper one for junk work, and restore the original when it’s done. The IDE doesn’t know it happened. Every mundane task costs 10-20x less quota.


The Problem

AI coding IDEs have usage limits. Every request burns quota. And not all requests are equal. An architecture discussion with Claude Opus eats way more quota than asking Gemini Flash to write a commit message.

But Antigravity doesn’t care. Whatever model you have selected, that’s what it uses for everything. Ask it to rename a variable? Opus. Format a JSON file? Opus. Write git commit -m "fix typo"? Opus.

The waste bothered me. So I asked: can the IDE switch its own model?

CDP

The Chrome DevTools Protocol is a WebSocket API that ships with every Chromium-based app. It was built for browser debugging, but it works on anything running Electron. You can query the DOM, evaluate arbitrary JavaScript, and simulate clicks on any element.

Antigravity’s model selector is just a list of buttons in a sidebar panel. CDP can see them. CDP can click them.

To enable it, add one line to ~/.antigravity/argv.json:

{
  "remote-debugging-port": 9222
}

Restart Antigravity. Now http://127.0.0.1:9222/json/list returns every window and tab. Connect via WebSocket and start talking to the DOM.

This is not an official API. It’s a debug interface. Fragile, undocumented, will break when they update the UI. But it works.

The Script

The full script is ag-model-switch.mjs. 380 lines.

Finding the Right Window

CDP exposes every Electron page, including internal ones you don’t want. The Launchpad, background agents, all of it. You filter for the actual workbench:

async function discoverTarget() {
  const res = await fetch(`http://127.0.0.1:9222/json/list`);
  const pages = await res.json();

  const target = pages.find(t =>
    t.type === 'page' &&
    t.webSocketDebuggerUrl &&
    !t.title?.includes('Launchpad') &&
    !t.url?.includes('workbench-jetski-agent') &&
    (t.url?.includes('workbench') || t.title?.includes('Antigravity'))
  );

  if (!target?.webSocketDebuggerUrl) {
    throw new Error('No Antigravity workbench page found.');
  }

  return target.webSocketDebuggerUrl;
}

A Minimal CDP Client

No Puppeteer. The script implements a bare CDP client using Node 24’s native WebSocket. About 100 lines total:

class CdpClient {
  #ws = null;
  #id = 1;
  #pending = new Map();
  #contexts = [];

  async connect(wsUrl) {
    this.#ws = new WebSocket(wsUrl);
    await new Promise((resolve, reject) => {
      this.#ws.onopen = resolve;
      this.#ws.onerror = () => reject(new Error('WebSocket failed'));
    });
    // ... message handling, context tracking
  }

  async evaluate(expression) {
    const contextId = this.getBestContextId();
    const res = await this.call('Runtime.evaluate', {
      expression,
      returnByValue: true,
      awaitPromise: true,
      contextId,
    });
    return res?.result?.value;
  }
}

The getBestContextId() method matters. Antigravity runs multiple execution contexts. The model selector lives in the cascade-panel context, so we prefer that one:

getBestContextId() {
  const cascade = this.#contexts.find(c =>
    c.url?.includes('cascade-panel')
  );
  if (cascade) return cascade.id;

  // Fallback: Extension context, then first available
  const ext = this.#contexts.find(c =>
    c.name?.includes('Extension')
  );
  return ext?.id ?? this.#contexts[0]?.id;
}

Reading the Model List

The model selector is a list of <button> elements with specific Tailwind CSS classes. Query the DOM, extract the names:

const items = Array.from(document.querySelectorAll('button.cursor-pointer'))
  .filter(el => el.className.includes('px-2 py-1 flex w-full items-center justify-between'));

return items.map(el => ({
  text: el.textContent.trim().replace(/New$/, '').trim(),
  cls: el.className
}));

The active model has a bg-gray- class without a hover: prefix. Inactive models only have hover:bg. That’s how you detect the current selection.

Switching Models

Find the target button. Check if it’s already active. Click it. Wait 600ms. Verify.

// Already selected? Do nothing.
if (isActive(target)) {
  return { ok: true, model: name, alreadySelected: true };
}

// Click to select
target.click();
await new Promise(r => setTimeout(r, 600));

// Verify the switch
const updated = document.querySelectorAll(MODEL_SELECTOR)
  .filter(el => el.className.includes(CLASS_FP));
const selected = updated.find(el =>
  clean(el).toLowerCase() === name.toLowerCase() && isActive(el)
);

return { ok: true, model: name, verified: !!selected };

CLI

Three modes:

# List all available models (marks the current one)
node ag-model-switch.mjs --list
#   Gemini 3 Flash
#   Gemini 3.1 Pro (Low)
#   Gemini 3.1 Pro ← current
#   Claude Sonnet 4.6 (Thinking)
#   Claude Opus 4.6 (Thinking)

# Show current model
node ag-model-switch.mjs --current
# Claude Opus 4.6 (Thinking)

# Switch (exact or fuzzy match)
node ag-model-switch.mjs "Gemini 3 Flash"
# OK: Switched to Gemini 3 Flash

node ag-model-switch.mjs "flash"
# OK: Switched to Gemini 3 Flash

Zero dependencies. Works with any model Antigravity supports.

Wiring It Into Workflows

The script alone is useful. The real win is automation.

I have a workflow called /gd that handles git commits. It analyzes diffs, groups changes into atomic commits, writes conventional commit messages, pushes. That’s a job that needs an AI model but doesn’t need a good one. Commit messages are Flash-tier work.

Here’s how the workflow starts:

# Step 0: Save current model and switch to cheap one
PATH="$HOME/.local/share/mise/installs/node/24.13.1/bin:$PATH" \
  node ag-model-switch.mjs --current > /tmp/.gd-original-model.txt

PATH="$HOME/.local/share/mise/installs/node/24.13.1/bin:$PATH" \
  node ag-model-switch.mjs "Gemini 3.1 Pro (Low)"

Everything runs on the cheap model. Then at the end:

# Step N: Restore original model
PATH="$HOME/.local/share/mise/installs/node/24.13.1/bin:$PATH" \
  node ag-model-switch.mjs "$(cat /tmp/.gd-original-model.txt)"

rm -f /tmp/.gd-original-model.txt

Save, switch, work, restore. Every /gd run now costs Flash-tier quota instead of Opus-tier. Zero quality difference for commit messages.

What Breaks

DOM selectors. The script targets Tailwind classes like px-2 py-1 flex w-full items-center justify-between. When Antigravity updates, these change. I’ve already had to update them once.

The model panel has to be visible. The script reads DOM elements in the sidebar. If the panel isn’t rendered, there’s nothing to query.

CDP requires a one-time restart. You add the remote-debugging-port config, restart, and forget about it.

This is a hack. No official API. I’m clicking invisible buttons through a debug protocol. Works perfectly until it doesn’t.

When it breaks, the fix takes about 10 minutes:

  1. Connect to CDP, probe the DOM for model names
  2. Find the new class fingerprint
  3. Update four constants:
const MODEL_ITEM_SELECTOR = 'button.cursor-pointer';
const MODEL_ITEM_CLASS_FP = 'px-2 py-1 flex w-full items-center justify-between';
const ACTIVE_CLASS_TOKEN = 'bg-gray-';
const INACTIVE_PREFIX = 'hover:bg';

Worth it.

The Point

AI coding tools have limits. You can’t control the pricing. But you can control how you spend your quota.

Commit messages and file renames are Flash-tier work. Architecture decisions and complex debugging are Opus-tier. The IDE treats them all the same. So you fix that yourself.


If you want the script, reach out. I’ll send it over.