works around upstream agy bug #76

agy·headless·bridge

PyPI PyPI downloads MCP Registry tests MIT

Call the Google Antigravity CLI (agy) headlessly — from a subprocess, an MCP server, CI, or another coding agent — and actually get its output back. Codename PtyGravity.

The problem

agy goes silent when nobody's at the terminal

agy -p "<prompt>" gates its stdout on isatty(). The moment stdout is not a real terminal — any automation — it prints nothing and exits 0. No error, no output, just emptiness.

BEFORE piped / subprocess

$ agy -p "say hi" | cat
$          # empty. exit 0. no error.

The popular winpty agy -p workaround needs a terminal that already exists — so it still fails from a subprocess, MCP server, or CI runner.

AFTER through the bridge

$ python -m agy_headless_bridge "say hi"
Hi! How can I help?

A fresh pseudo-terminal is allocated and agy attached to it. It sees a real tty, emits normally; the bridge strips ANSI/TUI noise and returns clean text.

flowchart TB
    subgraph BEF["❌ BEFORE — agy -p from any non-TTY caller"]
        direction TB
        a1["subprocess · MCP · CI · agent"] --> a2["agy -p prompt"]
        a2 --> a3["stdout gated by isatty()"]
        a3 --> a4["(empty string)
exit 0 · no error · no output"] end subgraph AFT["✅ AFTER — through agy-headless-bridge"] direction TB b1["subprocess · MCP · CI · agent"] --> b2["run(prompt)"] b2 --> b3["allocate fresh pseudo-terminal"] b3 --> b4["agy -p prompt
isatty() == True"] b4 --> b5["clean() strips ANSI/TUI"] b5 --> b6["clean text ✓"] end classDef bad fill:#2a1313,stroke:#f87171,color:#ffd9d9; classDef good fill:#0f2a1e,stroke:#34d399,color:#d7ffe9; class a1,a2,a3,a4 bad; class b1,b2,b3,b4,b5,b6 good;
Before vs after — the empty-output gate, and the pty that defeats it.
Architecture

How a non-TTY caller gets clean output

The bridge spawns agy attached to a brand-new pseudo-terminal that requires no pre-existing parent tty — so it works from anywhere.

flowchart TD
    A["Caller — non-TTY
Claude Code · MCP · subprocess · CI"] -->|"prompt"| B{{"run(prompt)"}} B --> C["find_agy()
$AGY_PATH → PATH → OS defaults"] C --> D{"sys.platform?"} D -->|"win32"| E["pywinpty
PtyProcess.spawn"] D -->|"posix"| F["stdlib pty
os.openpty + Popen"] E --> G(["fresh pseudo-terminal"]) F --> G G --> H["agy -p prompt
isatty == True → emits"] H -->|"raw bytes + ANSI/TUI chrome"| I["clean()
strip CSI/OSC · collapse \\r repaints · drop spinner glyphs"] I -->|"clean text"| A classDef br fill:#1b1740,stroke:#7c5cff,color:#e7e9ef; classDef pty fill:#0c2730,stroke:#22d3ee,color:#e7e9ef; classDef agy fill:#0f2a1e,stroke:#34d399,color:#e7e9ef; class B,C,I br; class E,F,G pty; class H agy;
Vertical data flow — same path on every platform, only the pty allocator differs.

The tty handshake, step by step

sequenceDiagram
    autonumber
    participant C as Caller (non-TTY)
    participant B as bridge.run()
    participant P as fresh PTY
    participant A as agy -p

    C->>B: run("prompt")
    B->>B: find_agy() + pick allocator
    B->>P: allocate pseudo-terminal
    B->>A: spawn, stdout→PTY master
    A->>A: isatty(stdout)==True ✓
    A-->>P: streams answer + TUI chrome
    loop until EOF / timeout
        B->>P: read(4096)
    end
    B->>B: clean() — strip ANSI, collapse \r, drop glyphs
    B-->>C: clean text
      
Bug #76 is an isatty() gate. Give agy a tty and it behaves.
Cross-platform

One API, two pty backends

PlatformBackendWhyStatus
Windowspywinpty · PtyProcessConPTY allocates a new pty with no parent-tty requirementverified
Linux / macOSstdlib pty · os.openpty + PopenNo third-party dep on POSIXpty CI ✓ · agy beta
⚠️ POSIX pty mechanics are verified on Linux CI (stub-driven). The real agy round-trip on macOS/Linux hasn't run on hardware yet — try it and report back. PRs welcome.
Functionality

The API & the output pipeline

One core function, three entry points around it. Everything routes through run().

Core contract

run(prompt: str, timeout: float = 180, agy_path: str | None = None) -> str
Raises / returnsWhen
AgyNotFoundErrorbinary not found via $AGY_PATHPATH → defaults
TimeoutErroragy exceeds AGY_BRIDGE_TIMEOUT — the process is killed, not left hanging
ValueErrorempty / whitespace prompt
"" (empty str)agy genuinely produced no output
clean strsuccess

What clean() strips

agy's pty output is a TUI stream, not plain text. clean() turns it back into the answer:

🎨

ANSI escapes

CSI & OSC sequences — colors, cursor moves, window-title sets.

🔁

\r repaints

A spinner overwrites one line many times; only the final paint survives.

📦

TUI chrome

Box-drawing & spinner glyphs ╭─╮ │ ⠋⠙⠹ are dropped.

Raw off the pty vs. what you get back

RAW off the pty

⠋ thinking…\r⠙ thinking…\r\x1b[2K
\x1b[32m╭─────────────╮\x1b[0m
\x1b[32m\x1b[0m A closure is a function
that captures variables from
the scope where it was defined.
\x1b[32m╰─────────────╯\x1b[0m

CLEANED returned to you

A closure is a function that
captures variables from the
scope where it was defined.

MCP response shape

{
  "jsonrpc": "2.0", "id": 2,
  "result": { "content": [ { "type": "text", "text": "<agy's cleaned answer>" } ] }
}

Both tools take one required string arg — agy_ask(prompt), agy_research(query). On failure the text is an [agy-mcp] ERROR: … string, not a JSON-RPC error, so the agent always gets a readable reply.

Troubleshooting

SymptomFix
pip install fails building pywinpty (Windows)Install MS C++ Build Tools, or use a CPython with a prebuilt wheel; python -m pip install -U pip first.
AgyNotFoundErrorSet AGY_PATH, or ensure agy --version works in your shell.
Empty string returnedConfirm agy -p "say hi" works in a real terminal — if not, it's agy/auth, not the bridge.
TimeoutErrorRaise AGY_BRIDGE_TIMEOUT (e.g. 600) or pass timeout= to run().
pty allocation failsWindows: reinstall pywinpty. POSIX: needs a real /dev/pts (some minimal containers lack it).
Garbled outputOpen an issue with OS + versions + raw output.

Three entry points

Entry pointInvokeUse for
Libraryfrom agy_headless_bridge import runembedding agy in Python
CLIagy-bridge "prompt"shell scripts, quick calls
MCP serverpython -m agy_headless_bridge.mcp_serveragents calling agy as a tool (agy_ask / agy_research)
Before you start

Prerequisites

This package only spawns the agy already on your machine — it ships no credentials and does not install or authenticate anything.

NeedDetail
Python 3.9+python --version
Antigravity CLI (agy)installed + authenticated — antigravity.google/cli. Auth once interactively (browser OAuth) or set ANTIGRAVITY_API_KEY.
Sanity checkIn a real terminal, agy -p "say hi" prints a reply. From a pipe it won't — that's the bug this fixes.
Windows onlypywinpty (auto-installed). POSIX uses stdlib pty — nothing extra.
Use cases

Wire it into your AI coding tools

Let one agent delegate work to Gemini via Antigravity, headlessly.

Use caseHow
Claude Code asks Gemini for a second opinion / diff reviewMCP server → agy_ask
CI step runs an agy prompt and captures the answeragy-bridge "..." in the workflow
Python pipeline fans work out to agyfrom agy_headless_bridge import run
Codex / any MCP-capable agent delegates to agyregister the same MCP server
Cron job summarizes logs via agyagy-bridge in the script

Wire into Claude Code

claude mcp add --transport stdio antigravity -- \
    python -m agy_headless_bridge.mcp_server
💬 Prompt to Claude Code: "Use the agy_ask tool to ask Antigravity to review this function for edge cases, then summarize its findings."

Want /agy:* slash commands + model selection too? Pair with the community antigravity-cc plugin — it handles triggering & model swap; this handles headless I/O.

Wire into Codex / any MCP client

{
  "mcpServers": {
    "antigravity": {
      "command": "python",
      "args": ["-m", "agy_headless_bridge.mcp_server"]
    }
  }
}
💬 Prompt to the agent: "Call agy_research with the query 'idiomatic error handling in Rust' and turn the result into a checklist."

From a shell / CI script

ANSWER="$(agy-bridge 'Summarize the key risk in this diff in one sentence.')"
echo "$ANSWER"
Quick start

Install & use

You still need the Antigravity CLI itself installed and authenticated. The bridge locates it via $AGY_PATHPATH → OS defaults.

Install

pip install agy-headless-bridge
# Windows pulls in pywinpty automatically; POSIX uses the stdlib pty module.

As a library

from agy_headless_bridge import run
print(run("Explain a closure in one line."))

As a CLI

python -m agy_headless_bridge "reply with exactly: OK"
# or the console script:
agy-bridge "reply with exactly: OK"

As an MCP server (Claude Code, etc.)

claude mcp add --transport stdio antigravity -- \
    python -m agy_headless_bridge.mcp_server

Exposes two tools — agy_ask and agy_research — so your agent can delegate work to Antigravity / Gemini.

What's in the box

Small, dependency-light, honest

🧩

Library + CLI + MCP

run() API, an agy-bridge console script, and a JSON-RPC stdio MCP server — pick your entry point.

🪶

No MCP SDK

Speaks JSON-RPC framing directly. Only dependency is pywinpty, and only on Windows.

🧼

Clean output

Strips CSI/OSC escapes, collapses \r spinner repaints, drops box-drawing chrome — you get just the answer.

⏱️

Timeout-safe

Configurable AGY_BRIDGE_TIMEOUT; kills a runaway agy and raises instead of hanging.

🔎

Auto-discovery

$AGY_PATHPATH → OS default install locations. No hardcoded paths.

Tested

Unit tests for cleaning & discovery always run; a live agy round-trip auto-skips when agy is absent.

Reference

Configuration

Env varDefaultMeaning
AGY_PATHauto-detectAbsolute path to the agy binary
AGY_BRIDGE_TIMEOUT180Seconds before a call is killed

Scope & non-goals

Model selection (swapping Gemini Pro / Flash / Claude inside agy) is not handled here — that's an agy settings concern, already covered by the antigravity-cc Claude Code plugin (it patches settings.json). Pair the two. This bridge fixes the I/O layer they're missing; it does not install or authenticate agy.