Running the Antigravity CLI (agy) Headless in CI: Working Around the Non-TTY stdout Problem
Run agy -p inside GitHub Actions or cron and the output you saw locally can vanish, while the exit code still returns 0. Here is how non-TTY detection causes it, plus a robust setup using a pseudo-TTY, defensive text parsing, and API-key auth so you always capture the result.
One morning I opened the GitHub Actions log and froze. The step that called agy -p "..." showed a green check — "success" — yet not a single line of output was there. Running the exact same command in my local terminal, the agent replied at length. In CI, the log held only blank space, and the exit code was 0. "Succeeded, but did nothing": the hardest failure mode to notice.
As an indie developer I run a few sites on unattended update pipelines, calling agents from the terminal as an extension of my personal tooling. Wiring the Antigravity CLI (the agy command) into that, I lost half a day to this trap. The cause was not a bug in my script — it was that agy changes its behavior depending on whether a human is watching the terminal.
This behavior is barely mentioned in the official tutorials, but it is fatal for unattended runs. Let me walk through how it reproduces and the robust setup I eventually settled on.
Why agy's output disappears in CI
An interactive agent CLI like agy checks at startup whether standard output is connected to a TTY (a terminal). When a person is interacting (a TTY), it streams a colored response and draws spinners and progress. When output is redirected to a pipe, a file, or a subprocess — a "non-TTY" — the CLI disables that terminal-oriented rendering.
The problem is that in the current version, agy --print / -p can drop the final response from standard output when run under a non-TTY. This has been reported as an issue in the Antigravity CLI (more below), and it stems from terminal rendering and "machine-readable stdout" not yet being cleanly separated. A GitHub Actions step, cron, subprocess.run(), a $(agy -p ...) command substitution — all of these are non-TTY, so they all hit the same symptom.
In other words, "works locally but goes empty in CI" is triggered not by environment variables, the network, or permissions, but by standard output not being connected to a terminal. Whether you can suspect this first dramatically changes your debugging time. For the basics of launching agy and reading its slash commands, see Getting hands-on with the Antigravity CLI: migration steps and slash commands.
A minimal repro: one pipe is enough to empty it
You don't need a server to reproduce this locally. Run the following in order and the symptom shows up directly.
# (1) Direct run — connected to the terminal, so you see the responseagy -p "Summarize the README in this directory in three lines"# (2) Add a single pipe — now non-TTY, and the output can come back emptyagy -p "Summarize the README in this directory in three lines" | cat# (3) Command substitution behaves the same — OUT is empty, exit code is 0OUT="$(agy -p "Summarize the README in this directory in three lines")"echo "captured length = ${#OUT}" # -> can be 0echo "exit code = $?" # -> 0 (treated as success)
In (1) the response is visible, but in (2) and (3) it can be empty. Worse, $? returns 0, so a naive script concludes "success" and moves on. That is exactly "succeeded but did nothing." A CI step is, in essence, in the same situation as (2) and (3).
Note: this non-TTY stdout drop is tracked as an issue in the Antigravity CLI repository (agy --print / -p silently drops stdout when run with a non-TTY). It may improve or change between versions, so always confirm the behavior with your own agy --version.
✦
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 can now diagnose why agy -p output silently disappears in CI or cron (non-TTY detection) and reliably capture it by attaching a pseudo-TTY
✦You'll learn a parsing pattern that does not depend on the still-unstable --output-format json, so your pipeline keeps working on plain text output
✦You'll be able to wire agy into unattended pipelines safely, accounting for API-key auth, exit codes, and idempotency
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.
Fix 1: hand it a pseudo-TTY to get the output back
The most reliable approach is to make the CLI believe it is connected to a terminal. Wrap it in a pseudo-terminal (pseudo-TTY) and you can capture output while terminal rendering stays enabled. The tooling differs by OS, so first check which one you have.
# Linux (including the GitHub Actions ubuntu runner): util-linux's script is most reliable# -q quiet / -e propagate the child's exit code to script's exit code / -c commandscript -qec 'agy -p "Summarize the README in three lines"' /dev/null | tee agy_out.txt# macOS (BSD script takes its arguments in a different order)script -q /dev/null agy -p "Summarize the README in three lines" | tee agy_out.txt# If expect is installed, unbuffer works too (relays output as-is)unbuffer agy -p "Summarize the README in three lines" | tee agy_out.txt
The key here is script's -e. Without it, you get script's own exit code (almost always success) and lose agy's real failure. The util-linuxscript on Linux supports -e, so in CI you generally use this form. BSD variants (the macOS default) order arguments differently and have no -e, so if you want to reuse the same script locally and in CI, branch on the OS to be safe.
A pseudo-TTY can mix terminal control characters (ANSI escapes, carriage returns) into the response. If you parse downstream, clean it once right after capture.
# Strip ANSI escapes and CR before savingscript -qec 'agy -p "..."' /dev/null \ | sed -r 's/\x1B\[[0-9;]*[A-Za-z]//g' \ | tr -d '\r' \ > agy_clean.txt
I rolled this "pseudo-TTY plus immediate sanitize" into a single shell function and route every entry point of my unattended runs through it. It guarantees both that I never drop output and that everything downstream stays stable, in one place at the entrance.
Fix 2: don't rely on --output-format json — parse text safely
"Then just emit JSON from the start" is the natural reaction. Indeed, the docs and samples mention --output-format json. But in the current version, passing --output-format is reported to be rejected with flags provided but not defined: -output-format — structured output is not a stable feature yet.
The call here is simple: don't build on a flag that doesn't work yet. Until structured output stabilizes, parsing text output on the assumption that it "won't crash even when malformed" is what keeps operations stable. I use a plain two-step check: (1) the output is non-empty, and (2) it contains the marker I expect.
#!/usr/bin/env bashset -euo pipefail# Shared function: call agy via a pseudo-TTY and capture output into a variablerun_agy() { local prompt="$1" # script's -e propagates agy's exit code script -qec "agy -p \"${prompt}\"" /dev/null \ | sed -r 's/\x1B\[[0-9;]*[A-Za-z]//g' \ | tr -d '\r'}OUT="$(run_agy 'Turn this diff into a one-line commit message. Prefix it with COMMIT:' || true)"# (1) Empty check — reliably detects non-TTY drops and silent failuresif [ -z "${OUT//[[:space:]]/}" ]; then echo "::error::agy returned empty output (likely non-TTY capture failure)" >&2 exit 1fi# (2) Marker extraction — decide success by whether the expected format is presentMSG="$(printf '%s\n' "$OUT" | grep -m1 '^COMMIT:' | sed 's/^COMMIT:[[:space:]]*//' || true)"if [ -z "$MSG" ]; then echo "::error::expected COMMIT: marker not found in agy output" >&2 printf '%s\n' "$OUT" >&2 # keep raw output for debugging exit 1fiecho "commit message = ${MSG}"
There are two ideas here. On the prompt side, fix the output format yourself by saying "prefix it with COMMIT:." On the receiving side, use only the presence of that fixed marker to judge success. Trying to pry open the model's free-form prose with regex breaks easily as responses drift. Make a single self-chosen marker line your contract, and the pipeline stays stable no matter how the prose changes. If --output-format json becomes stable, you only swap that grep layer for a JSON parse — keeping the structure means a smooth migration.
Pass credentials as an API key, not OAuth
The other headless trap is authentication. Start agy normally and it prompts for a browser-based OAuth login, but CI has no browser. Call agy login naively here and the job times out waiting for input.
In an unattended environment, pass an API key through an environment variable instead of logging in interactively. Set GEMINI_API_KEY (a Google AI Studio key) or ANTIGRAVITY_API_KEY, and agy can start without an interactive step. In GitHub Actions, always store it in Secrets and keep it out of logs.
# env section of .github/workflows/agy.yml (excerpt)env: GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} # Never echo the key. Being explicit about masking is reassuring.
# Defense at the top of the script — fail immediately if the key is unset: "${GEMINI_API_KEY:?GEMINI_API_KEY is required for headless agy}"# Never print the value itself, just confirm it existsecho "auth: GEMINI_API_KEY is set (length hidden)"
Passing an API key directly also has the practical benefit of not being jerked around by OAuth token expiry. In my experience, the more you steer unattended-pipeline auth toward "a path with no interaction at all," the fewer overnight and weekend failures you get. If you use sandbox isolation, a fix landed so that --sandbox propagates even in -p (print) mode, so you don't have to give up isolated execution. Confirm how your version handles it with agy --help.
Exit codes and idempotency — preventing "succeeded but did nothing"
The heart of my first incident was trusting the exit code. If agy returns 0 even when output is empty under a non-TTY, any pipeline that judges success on exit code alone will misfire. I now hold to three rules.
First, make both the exit code and the output content success conditions. Only treat it as success when $? -eq 0 and "the expected marker is present in the output." The script in the previous section is exactly this two-stage gate.
Second, make any side-effecting work idempotent. If you commit or generate files based on the agent's output, design it so running twice on the same input does not multiply results. For example, key a generated artifact by its content hash and do nothing if it matches an existing one. Unattended runs pair well with retries, but if retries create duplicates, cleanup quickly becomes a burden. I dig into idempotency further in Building idempotent scheduled agents with the Antigravity SDK.
Third, add a timeout and a single retry. Agents sometimes think for a long time. Cap it with timeout, and if the output is empty, wait briefly and rerun once; if it is still empty, fail and notify a human. That bit of pragmatism makes operations much easier.
Here are the parts assembled into a minimal workflow. The point is to enclose the pseudo-TTY, empty check, API key, and timeout in a single step, and have downstream read only a "validated output file."
name: agy-headlesson: workflow_dispatch:jobs: run: runs-on: ubuntu-latest timeout-minutes: 10 env: GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} steps: - uses: actions/checkout@v4 - name: Install Antigravity CLI (agy) run: | # Distribution differs by version, so follow the latest official steps # e.g. official installer or release binary, then verify with agy --version agy --version - name: Run agy headless and capture output run: | set -euo pipefail : "${GEMINI_API_KEY:?missing key}" # Run under a pseudo-TTY -> sanitize -> file script -qec 'agy -p "Summarize the changes in one line, prefixed with SUMMARY:"' /dev/null \ | sed -r 's/\x1B\[[0-9;]*[A-Za-z]//g' | tr -d '\r' > agy_out.txt # Empty check (last line of defense against non-TTY drops) test -s agy_out.txt || { echo "::error::empty agy output"; exit 1; } grep -q '^SUMMARY:' agy_out.txt || { echo "::error::no SUMMARY marker"; cat agy_out.txt; exit 1; } - name: Use validated output run: | SUMMARY="$(sed -n 's/^SUMMARY:[[:space:]]*//p' agy_out.txt | head -1)" echo "summary = $SUMMARY"
The script on ubuntu-latest is the util-linux version, so -e and -c work as written. If you switch runs-on to macOS, just adjust for the different script argument order noted above. Installation steps vary by version and distribution format, so don't hardcode them here — defer to the latest official steps and a connectivity check with agy --version.
Closing
If your pipeline currently calls agy non-interactively, try just one thing first: wrap the place where you capture output in script -qec '...' /dev/null, then make "the output is non-empty" an explicit check. Those two changes alone prevent most of the hardest-to-notice failure: "succeeded but did nothing."
Once --output-format json stabilizes and the non-TTY stdout drop is resolved, the pseudo-TTY trick here should eventually become unnecessary. Until then, I hope this serves as a practical note for safely crossing that transition — especially if you've fallen into the same trap.
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.