How to build a Google Antigravity Agent That Fixes Failing CI with AG-UI and CopilotKit

By Anmol Baranwal
November 3, 2026
How to build a Google Antigravity Agent That Fixes Failing CI with AG-UI and CopilotKit

Antigravity is Google's agentic development platform. Its Python SDK brings the same agent harness into your own code, with built-in tools to read files, run shell commands and make edits.

The Antigravity AG-UI SDK (ag-ui-antigravity) streams every step of that agent over AG-UI, the open protocol between agents and user-facing apps. Connect it to CopilotKit and those steps show up in your UI as they happen, from tool calls and live state to approval prompts for risky actions.

In this tutorial, we'll build Repo Doctor, an app that fixes broken CI on GitHub repos. It reads the CI results, runs the tests, reproduces the failure, then fixes the code, shows the change as a diff and proves the fix before pushing it. You approve each step or let auto-approve handle it, then download a report of the whole run.

Every conversation is saved in CopilotKit Intelligence and opens on any device with its full history, while Product Analytics shows how the agent is used. The whole app runs as one Cloud Run service on Google Cloud, with each conversation's workspace backed up to Cloud Storage.

GitHub Source Code: CopilotKit/examples/showcases/antigravity-repo-doctor
Cookbook docs: docs.copilotkit.ai/cookbook/antigravity-repo-doctor

1. Introduction to Antigravity

An agent harness is the code around the model. It sends the conversation to the model, runs the tools the model asks for, feeds the results back and decides when the turn is done. Antigravity gives you Google's own harness, shipped as a compiled binary inside the google-antigravity package, and your Python code drives it over a local connection.

Out of the box, the harness can list, search, view and edit files, run shell commands, search the web and hand work to subagents. It only touches the folders you pass in workspaces, so you decide where the agent is allowed to work.

Your own tools are plain Python functions, and the SDK builds each tool's schema from its signature and docstring. Hooks run before every tool call and can allow or block it, which is how Repo Doctor asks you before anything risky runs.

The Antigravity AG-UI SDK wraps that agent in an AG-UI server. Every step the harness takes streams to the app as a typed event: text as it's written, tool calls with their results, state updates, and an interrupt when a tool needs your approval.

from ag_ui_antigravity import AntigravityAgent, create_antigravity_app

agent = AntigravityAgent(
    model="gemini-3.5-flash",
    api_key="...",                       # or GEMINI_API_KEY in the environment
    system_instructions="You are a helpful assistant.",
    workspaces=["/path/to/a/sandbox"],
)

app = create_antigravity_app(agent, path="/")

create_antigravity_app returns a FastAPI app, so the agent runs as a regular web service.

On the frontend, CopilotKit connects to the agent over AG-UI and turns each event into UI, from a card for each tool call and a side panel that follows the agent's state to Approve and Deny buttons for an interrupt.

__wf_reserved_inherit

2. What are we building?

Repo Doctor takes a GitHub repo with red CI, finds out why it’s failing and gets it back to green. Here’s the flow:

  • you ask about a repo or click a suggested prompt
  • the agent clones the repo and finds the failing step in its GitHub Actions results
  • it installs the project and runs the tests, with each run as a test results card
  • it reruns the failing step locally as one short check
  • it edits a fix and shows it as a diff
  • it runs the same check again, and once it passes and the tests go green, it pushes a fix branch

Installs, file edits, shell commands and pushes each wait for you to approve or deny them. Pushes go to a local copy of the repo, so nothing reaches GitHub.

Here’s the architecture. The CopilotKit runtime sends each message over AG-UI to the Python agent, which plans each step with Gemini and runs the tools, all on Cloud Run.

__wf_reserved_inherit

3. Tech stack

Here’s the stack behind Repo Doctor.

  • Antigravity SDK - the agent harness, with built-in tools to read, edit and run code
  • Antigravity AG-UI SDK - streams the agent’s steps, state and approval interrupts over AG-UI
  • CopilotKit - the chat, the runtime and the hooks that render tool calls, state and approvals
  • A2UI - the format the agent uses to describe each test results card
  • CopilotKit Intelligence - saves every conversation and powers Product Analytics
  • Gemini - the model behind the agent (gemini-3.5-flash)
  • FastAPI - the Python server the agent runs on
  • Next.js - the web app and the runtime route
  • Cloud Run - hosts the app and the agent in one container
  • Secret Manager - stores the Gemini, Intelligence and GitHub keys
  • Cloud Storage - backs up each conversation’s workspace

4. Connect an Antigravity agent to CopilotKit

Repo Doctor has two parts: a Next.js app with the chat and the CopilotKit runtime, and the Python agent in agent/. We’ll connect the chat to the agent first, then give the agent its CI tools in the next section.

You'll need Node.js 22 or newer, pnpm, Python 3.13, uv and a Gemini API key from Google AI Studio.

Step 1: Create the Next.js app

Create the app and add the CopilotKit packages:

pnpm create next-app@latest repo-doctor --yes
cd repo-doctor
pnpm add @copilotkit/react-core @copilotkit/runtime @copilotkit/a2ui-renderer @ag-ui/client zod@3

@copilotkit/a2ui-renderer draws the test results card, and @ag-ui/client lets the runtime talk to the Python agent. The A2UI catalog needs zod 3, so zod stays on version 3.

The CopilotKit provider wraps the app. It points at the runtime route, registers the A2UI catalog we'll build in the next section, and turns on the CopilotKit Inspector in development (useful for debugging).

"use client";

import {
  CopilotChatConfigurationProvider,
  CopilotKit,
} from "@copilotkit/react-core/v2";
import { catalog } from "@/a2ui/catalog";
// ...

const A2UI = { catalog };

export function AppShell(props: ShellProps) {
  return (
    <CopilotKit
      runtimeUrl="/api/copilotkit"
      useSingleEndpoint={false}
      a2ui={A2UI}
      enableInspector={process.env.NODE_ENV === "development"}
    >
      <CopilotChatConfigurationProvider agentId={AGENT_ID}>
        {/* ... */}
      </CopilotChatConfigurationProvider>
    </CopilotKit>
  );
}

The chat is one CopilotChat component pointed at the agent. Its welcomeScreen prop replaces the empty chat with a start screen of example repos, each showing its CI status and a suggested prompt.

<CopilotChat
  agentId={AGENT_ID}
  className="h-full"
  welcomeScreen={Welcome}
/>

Step 2: Create the Antigravity agent

The agent lives in agent/ as a uv project. Create it and add the Antigravity SDK along with its AG-UI adapter.

uv init --package --name repo-doctor agent
cd agent
uv add ag-ui-antigravity==0.2.1 google-antigravity==0.1.22 ag-ui-protocol==1.0.0 fastapi "uvicorn[standard]" pydantic pydantic-settings

The agent is an AntigravityAgent from the Antigravity AG-UI SDK, wrapped in a small subclass, ThreadScopedAntigravityAgent, that gives each conversation its own workspace folder.

The config picks the model and the prompt, lists the built-in tools the agent may use, points the harness at a workspace and turns on approvals.

def create_agent(settings: Settings) -> ThreadScopedAntigravityAgent:
    # ...
    return ThreadScopedAntigravityAgent(
        settings=settings,
        model=settings.gemini_model,
        api_key=settings.gemini_api_key.get_secret_value(),
        system_instructions=SYSTEM_INSTRUCTIONS,
        capabilities=CapabilitiesConfig(enabled_tools=ENABLED_BUILTINS, enable_subagents=False),
        workspaces=[str(settings.workspace_root)],
        tool_approval=True,
        auto_approve_tools=list(AUTO_APPROVED),
        # ...
    )

tool_approval=True pauses every tool call that isn’t in auto_approve_tools:

  • runs on its own: viewing files, cloning, checking CI, running the tests and showing the diff
  • waits for you: installs, checks, file edits, shell commands and pushes

ENABLED_BUILTINS turns on the file and shell tools, plus finish, which the harness needs to end a turn. SYSTEM_INSTRUCTIONS tells the model which tool to use when, and that a fix only counts once a check goes from failed to passed.

To serve the agent, create_antigravity_app turns it into an AG-UI endpoint at POST /.

from ag_ui_antigravity import create_antigravity_app

# ...

def create_app() -> FastAPI:
    settings = get_settings()
    agent = create_agent(settings)
    app: FastAPI = create_antigravity_app(agent, path="/")
    return app


def main() -> None:
    settings = get_settings()
    uvicorn.run(create_app(), host=settings.agent_host, port=settings.agent_port)

The settings, shell and workspace helpers are small files you can copy from the agent folder.

The app and the agent share one .env at the project root. GITHUB_TOKEN is optional and raises the CI lookups from 60 to 5,000 an hour.

GEMINI_API_KEY=...
GITHUB_TOKEN=...   # optional
AGENT_URL=http://127.0.0.1:8000/

Start the agent from agent/ with uv run python -m repo_doctor.server. It listens on 127.0.0.1:8000, and only the Next.js server talks to it.

Step 3: Connect the CopilotKit runtime

The runtime route sits between the chat and the agent. HttpAgent sends each run to the agent’s URL over AG-UI, and the a2ui option turns on the A2UI middleware, which passes the agent’s test results cards to the chat.

// src/app/api/copilotkit/[[...slug]]/route.ts
import { HttpAgent } from "@ag-ui/client";
import { CopilotRuntime, createCopilotRuntimeHandler } from "@copilotkit/runtime/v2";

const agent = new HttpAgent({ url: env.AGENT_URL });

const runtime = new CopilotRuntime({
  agents: { [AGENT_ID]: agent },
  a2ui: { injectA2UITool: false },
  // ...
});

const handler = createCopilotRuntimeHandler({ runtime, basePath: "/api/copilotkit" });

export const GET = handler;
export const POST = handler;
export const PATCH = handler;
export const DELETE = handler;

The runtime has no model config, because the harness calls Gemini itself. injectA2UITool: false tells the runtime that the agent writes its own A2UI, so the model doesn’t get an extra render tool.

With the agent running, start the app with pnpm dev in a second terminal. Open http://localhost:3000, say hello, and the Antigravity agent answers in the chat.

5. Give the agent its CI tools and show them in the chat

With the chat connected, the agent gets its CI tools, and each tool gets its own piece of UI.

Step 1: Add the CI tools

The built-in tools already read and edit code. The CI work lives in seven tools in tools.py:

  • clone_repo makes a shallow clone of the repo
  • ci_status reads the latest GitHub Actions results and names the failing jobs and steps
  • install_dependencies installs the project
  • run_tests runs the test suite
  • run_check runs one command that reproduces the CI failure
  • show_diff returns the real git diff
  • git_push commits the fix on a new branch and pushes it

Each one is a plain async function with a docstring, and the SDK offers it to the model next to the built-in tools.

Tools also write what happened into shared state, which streams to the browser for the side panel. experimental_set_state replaces the whole state, so update_state merges each change into it.

from ag_ui_antigravity import experimental_get_state, experimental_set_state

def _update_state(**changes: Any) -> None:
    experimental_set_state({**_state(), **changes})

clone_repo uses _update_state to save the repo’s details under repo. It also keeps pushes away from GitHub. The GitHub remote becomes upstream with pushing turned off, and a local bare repo becomes origin, so git_push only ever pushes to that local copy.

All seven tools are bound to the conversation’s folder, and ThreadScopedAntigravityAgent builds a fresh set for each conversation, so two conversations never share a checkout.

__wf_reserved_inherit

Step 2: Prove the fix with a check

Green tests alone don’t prove the fix addressed what CI saw. So the agent turns the failing CI step into one short command and runs it twice, once before the fix to reproduce the failure and once after to confirm it’s gone.

async def run_check(command: str, label: str) -> dict[str, Any]:
    """Runs one shell command that reproduces a failing CI step, and records it."""
    # ...
    result = await shell.run(
        [shell_bin, "-c", command], cwd=repo, timeout=CHECK_TIMEOUT_SECONDS, env=_check_env()
    )
    exit_code = result.exit_code
    status = "passed" if exit_code == 0 else "failed"
    checks: list[dict[str, Any]] = list(_state().get("checks", []))
    record = {
        "run": len(checks) + 1,
        "label": label,
        "command": command,
        "status": status,
        # ...
    }
    checks.append(record)
    _update_state(checks=checks)
    return {"ok": True, **record}

The exit code decides passed or failed. Both runs land in checks, so the side panel shows the same check failing and then passing, and the agent quotes both in its final summary.

__wf_reserved_inherit
__wf_reserved_inherit

Step 3: Approve or deny risky steps

When the model calls a tool that needs approval:

  • the Antigravity AG-UI SDK holds the call and ends the run with an interrupt
  • the interrupt’s reason is tool_approval, and its metadata carries the tool’s name and arguments
  • useInterrupt renders a card for it, and resolve sends your answer back to the held call
useInterrupt({
  agentId: AGENT_ID,
  enabled: (event) =>
    (event.value as { reason?: string } | undefined)?.reason === "tool_approval",
  render: ({ interrupt, resolve }) => {
    const { tool_name, args, ...meta } = (interrupt?.metadata ?? {}) as {
      tool_name?: string;
      args?: Record<string, unknown>;
    } & Record<string, unknown>;
    const tool = tool_name ?? "tool";
    return (
      <ApprovalCard
        key={interrupt?.id}
        request={{ tool, args: args ?? {}, meta }}
        autoApprove={autoApprove && !ALWAYS_ASK.has(tool)}
        onAnswer={(answer: ApprovalAnswer) => resolve(answer, interrupt?.id)}
      />
    );
  },
});

The card shows exactly what’s about to run, from the install command to the lines an edit changes or the branch a push creates. Approve sends { approved: true } and the held call runs. Deny sends { approved: false, message }. The call is skipped, and your note tells the agent why, so it can try another way or ask you.

The Auto-approve switch in the navbar answers the cards for you, except for pushes. That’s the ALWAYS_ASK check in the snippet.

The Auto-approve switch in the navbar answers the cards for you, except for pushes. That’s the ALWAYS_ASK check in the snippet.

__wf_reserved_inherit

Step 4: Show the fix as a diff

show_diff returns the workspace’s real git diff with a one-line summary from the model. useRenderTool matches the tool call by name and draws it in the chat.

const Any = z.record(z.unknown());

useRenderTool({
  name: "show_diff",
  agentId: AGENT_ID,
  parameters: Any,
  render: (p) => <DiffBlock status={p.status} result={p.result} />,
});

DiffBlock waits for the call to complete, then draws each changed file with its added and removed lines. useRenderTool draws a row or card for each of the other tools the same way.

__wf_reserved_inherit

Step 5: Draw the test results with A2UI

With A2UI, the agent describes a card as data and the app draws it with its own component. Each card lives in a surface, its own slot in the chat. run_tests runs the suite, turns the output into a TestReport and returns a2ui_operations next to the results the model reads.

async def run_tests(target: str = "") -> dict[str, Any]:
    """Runs the test suite and shows a live report card to the user."""
    # ...
    return {
        "a2ui_operations": report_surface_operations(report, run=number),
        "status": report.status,
        "summary": report.headline(),
        # ...
    }

report_surface_operations builds three operations:

  • createSurface opens a new surface tied to the catalog
  • updateComponents sets its one TestReport component (COMPONENTS never changes)
  • updateDataModel fills in that run’s data
CATALOG_ID = "https://repo-doctor.dev/a2ui/catalog/v1"

def report_surface_operations(report: TestReport, *, run: int) -> list[dict[str, Any]]:
    sid = surface_id(run)
    return [
        {"version": "v0.9", "createSurface": {"surfaceId": sid, "catalogId": CATALOG_ID}},
        {"version": "v0.9", "updateComponents": {"surfaceId": sid, "components": COMPONENTS}},
        {
            "version": "v0.9",
            "updateDataModel": {"surfaceId": sid, "path": "/", "value": report_data(report, run=run)},
        },
    ]

Each run gets its own surface id (test-run-1, test-run-2 and so on), so a new run adds a new card and the earlier ones stay in the chat.

__wf_reserved_inherit

On the web side, createCatalog maps TestReport onto the app’s own React component. It uses the same catalog id as the agent, which is how the two sides find each other. This is the catalog the provider registered in section 4.

export const CATALOG_ID = "https://repo-doctor.dev/a2ui/catalog/v1";

const renderers: CatalogRenderers<Definitions> = {
  TestReport: ({ props }) => (
    <TestReport
      run={num(props.run)}
      status={str(props.status)}
      headline={str(props.headline)}
      files={list<FileResultData>(props.files)}
      failures={list<FailedTestData>(props.failures)}
      summary={str(props.summary)}
    />
  ),
};

export const catalog = createCatalog(definitions, renderers, {
  catalogId: CATALOG_ID,
  includeBasicCatalog: false,
});

definitions holds a zod schema for the card’s props, which is the contract between the agent and the UI. With includeBasicCatalog: false, TestReport is the only component the agent can draw.

__wf_reserved_inherit

Step 6: Track the run in a side panel

By now every tool has written its part into shared state: repo, install, testRuns, checks, approvals and push. useAgent gives the browser that state, so the side panel updates the moment a tool finishes.

const { agent } = useAgent({ agentId: AGENT_ID });
const state = useMemo(() => parseAgentState(agent.state), [agent.state]);

parseAgentState checks the state with zod first, since it comes over the network. The panel then shows the repo, the check going from failed to passed, every test run, every approval and the pushed branch.

The Download report button turns the same state into a Markdown report of the whole run, right in the browser.

__wf_reserved_inherit

6. CopilotKit Intelligence: saved conversations and product analytics

CopilotKit Intelligence saves every conversation under the person who started it, and turns the runs into Product Analytics. Connect a project with the CopilotKit CLI, which writes CPK_INTELLIGENCE_API_KEY to .env.

npx copilotkit@latest login
npx copilotkit@latest project select --create repo-doctor --env-name .env

Then add Intelligence to the runtime from section 4, in route.ts.

const runtime = new CopilotRuntime({
  agents: { [AGENT_ID]: agent },
  a2ui: { injectA2UITool: false },
  intelligence: new CopilotKitIntelligence({ apiKey: env.CPK_INTELLIGENCE_API_KEY }),
  generateThreadNames: false,
  identifyUser: (request: Request) =>
    userFromCookieHeader(request.headers.get("cookie")) ?? {
      id: env.DEMO_USER_ID,
      name: "Demo user",
    },
});

identifyUser tells Intelligence who made each request. Repo Doctor reads it from the name you type in the navbar, stored as a cookie by user.ts. That works for a demo, so swap in your own login before real users get the link.

generateThreadNames is off because naming a thread would run the whole agent a second time, so the conversation list names each thread from its first message. Without an Intelligence key, the runtime keeps runs in memory instead.

AG-UI Streams (Threads)

Every conversation is saved as a thread with its messages, tool calls, cards and state. The conversation list in the app is one hook, useThreads.

const { threads, isLoading, archiveThread, deleteThread } = useThreads({ agentId: AGENT_ID, limit: 50 });

Type the same name on another device and you’re back in the conversation, with the cards, test results and approvals where you left them.

In the Intelligence dashboard, Threads shows every event of every run. A run that stopped for an approval ends with an interrupt outcome holding the exact command that’s waiting for you.

Product Analytics

Product Analytics reads the same runs, with nothing extra to set up. The Activity tab shows how many people used the agent and which tools it calls most. The Reliability tab shows the error rate over time and the share of runs that finished cleanly.

__wf_reserved_inherit
__wf_reserved_inherit

7. Running it locally

Clone the repo and install the dependencies.

git clone --depth 1 https://github.com/CopilotKit/CopilotKit.git
cd CopilotKit/examples/showcases/antigravity-repo-doctor
pnpm install

Run pnpm dev. The first run creates a .env file, sets up the Python agent, then stops because the Gemini key is empty. Open .env and paste your key:

GEMINI_API_KEY=...

If you’ve been following along, the two CLI commands from section 6 already set up CopilotKit Intelligence and added its API key to .env.

Run pnpm dev again and open http://localhost:3000. The agent runs code from the repos you give it, so only use repos you trust.

__wf_reserved_inherit

Here’s the agent fixing CI on google-gemini/genai-processors from start to finish.

Debug it with the CopilotKit Inspector

Click the CopilotKit logo in the corner of the app to open the CopilotKit Inspector. Its AG-UI Events tab shows every tool call streaming in, the ACTIVITY_SNAPSHOT that carries each test results card and the STATE_SNAPSHOT the side panel reads.

__wf_reserved_inherit

8. Deploying to Google Cloud

The whole app runs as one Cloud Run service: the Next.js app with the CopilotKit runtime and the Python agent with the harness, in one container. Run these from the app folder with the gcloud CLI, after gcloud auth login.

Build the image with Cloud Build:

PROJECT=your-project
REGION=us-central1
IMAGE=$REGION-docker.pkg.dev/$PROJECT/repo-doctor/app
gcloud config set project $PROJECT

gcloud services enable run.googleapis.com artifactregistry.googleapis.com \
  cloudbuild.googleapis.com secretmanager.googleapis.com
gcloud artifacts repositories create repo-doctor --repository-format=docker --location=$REGION
gcloud builds submit --tag $IMAGE
__wf_reserved_inherit

Put the keys in Secret Manager, straight from .env without printing them. Then create a service account that can only read those secrets and one bucket, and the bucket that holds each conversation's workspace:

secret() { grep "^$1=" .env | cut -d= -f2- | tr -d '\n"' | gcloud secrets create "$2" --data-file=-; }
secret GEMINI_API_KEY gemini-api-key
secret CPK_INTELLIGENCE_API_KEY cpk-intelligence-api-key
secret GITHUB_TOKEN github-token

gcloud iam service-accounts create repo-doctor --display-name="Repo Doctor"
SA=repo-doctor@$PROJECT.iam.gserviceaccount.com
# A new service account takes a few seconds to appear, so each binding retries.
for s in gemini-api-key cpk-intelligence-api-key github-token; do
  for try in 1 2 3 4 5 6; do
    gcloud secrets add-iam-policy-binding $s --member=serviceAccount:$SA \
      --role=roles/secretmanager.secretAccessor && break
    sleep 10
  done
done

BUCKET=$PROJECT-repo-doctor-ws
gcloud storage buckets create gs://$BUCKET --location=$REGION --uniform-bucket-level-access
gcloud storage buckets add-iam-policy-binding gs://$BUCKET \
  --member=serviceAccount:$SA --role=roles/storage.objectUser
__wf_reserved_inherit

Deploy it as a private service:

gcloud run deploy repo-doctor \
  --image $IMAGE --region $REGION --service-account $SA \
  --min-instances 1 --max-instances 1 \
  --cpu 4 --memory 16Gi --no-cpu-throttling \
  --timeout 3600 --execution-environment gen2 \
  --set-secrets GEMINI_API_KEY=gemini-api-key:latest,CPK_INTELLIGENCE_API_KEY=cpk-intelligence-api-key:latest,GITHUB_TOKEN=github-token:latest \
  --set-env-vars GEMINI_MODEL=gemini-3.5-flash,DEMO_USER_ID=repo-doctor-demo,WORKSPACE_BUCKET=gs://$BUCKET \
  --no-allow-unauthenticated

APP_URL=$(gcloud run services describe repo-doctor --region $REGION --format='value(status.url)')
gcloud run services update repo-doctor --region $REGION --update-env-vars APP_URL=$APP_URL

A paused approval waits in the agent's memory, so the service runs exactly one instance that never scales to zero. --no-cpu-throttling keeps the agent working while it waits for you, and --timeout 3600 covers long runs.

__wf_reserved_inherit

After every run, the agent saves the conversation to its own folder in the bucket: the repo with its edits, the pushed fix branches and the agent's session. If the service restarts, the next message in that conversation brings everything back and the agent picks up where it left off.

The service is private, and it should stay that way: the cloned repos' code runs inside it, and every message spends your Gemini credits. Open it through an authenticated proxy at http://localhost:8080.

gcloud run services proxy repo-doctor --region $REGION --port 8080
__wf_reserved_inherit

Get started

Repo Doctor is one way to put a real interface on an Antigravity agent. Swap the CI tools for your own, and the same AG-UI wiring gives you approvals, live cards and saved conversations.

Follow CopilotKit on Twitter and join the Discord community. If you get stuck, reach out in the CopilotKit or AG-UI communities.

Are you ready?

Stay in the know

Subscribe to our blog and get updates on CopilotKit in your inbox.