ANTIGRAVITY LABJP
Articles/Integrations
Integrations/2026-08-22Intermediate

Driving Antigravity CLI with stream-json Input: Where to Split the Conversation

The print mode now accepts --input-format stream-json, letting an external driver hold one session open and feed it jobs one at a time. I measured how much a single conversation carries across 24 jobs, found that a byte budget splits work in the wrong place, and worked out how to choose the split boundary instead.

Antigravity CLI29stream-jsonbatch processingautomation90Node.js5

Premium Article

As an indie developer with six apps to keep current — wallpaper apps and a couple of calm-focused ones — I rewrite the release notes for each language every time I ship. Japanese first, then English, then Korean and Traditional Chinese. One release round means twenty-four short pieces of text.

Until recently I launched the CLI once per item. Twenty-four launches, twenty-four startup costs, and twenty-four fresh starts with no memory of how I phrased the previous language. That is exactly where the English notes for the third app end up using a different word for the same feature.

CLI 1.1.15, released on August 19, added --input-format stream-json to print mode. It reads newline-delimited JSON from stdin and runs one turn per message inside the same conversation. An external driver can hold the session open and feed it work.

That changes the shape of the problem. If the conversation stays open, how much can it carry, and where should it end? I measured it, and the answer I had been assuming turned out to be wrong.

What "one message, one turn" actually costs

Each line on stdin becomes one user message:

{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Draft release notes for wallpaper-a in Japanese, four lines max"}]}}

The invocation looks like this:

agy --print \
    --input-format stream-json \
    --output-format stream-json \
    < jobs.ndjson

The part worth sitting with: because the conversation never closes, history keeps accumulating. Turn 2 carries turn 1. Turn 24 carries twenty-three turns of context. That accumulation is exactly why the wording stays consistent — and exactly why the run gets heavier as it goes.

So what --input-format stream-json really handed over was not a convenient input format. It handed over the decision of when a conversation ends. Once you own that decision, you need a rule for making it.

A minimal external driver

Start with a driver that waits for each turn to finish before sending the next one. That single constraint removes most of the unpredictability.

// stream-driver.mjs — drive the CLI over stream-json from outside
import { spawn } from "node:child_process";
import readline from "node:readline";
 
export async function runBatch(jobs, opts = {}) {
  const cmd = opts.cmd ?? ["agy", "--print", "--input-format", "stream-json", "--output-format", "stream-json"];
  const budget = opts.budgetBytes ?? 40_000; // ceiling for one conversation
  const stats = { sessions: 0, turns: 0, peakCarried: 0, splits: [] };
 
  let child = null, rl = null, carried = 0;
 
  const open = () => {
    child = spawn(cmd[0], cmd.slice(1), { stdio: ["pipe", "pipe", "inherit"] });
    rl = readline.createInterface({ input: child.stdout }); // let readline own line boundaries
    carried = 0;
    stats.sessions += 1;
  };
  const close = () => { child?.stdin.end(); rl?.close(); child = null; };
 
  // send one message, wait for that turn's terminal event
  const sendAndWait = (text) =>
    new Promise((resolve, reject) => {
      const onLine = (line) => {
        let ev;
        try { ev = JSON.parse(line); } catch { return; } // drop partial or decorated lines
        if (ev.type === "result") { rl.off("line", onLine); resolve(ev); }
        if (ev.type === "error") { rl.off("line", onLine); reject(new Error(ev.reason)); }
      };
      rl.on("line", onLine);
      child.stdin.write(
        JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text }] } }) + "\n"
      );
    });
 
  open();
  for (const job of jobs) {
    const size = Buffer.byteLength(job, "utf8");
    if (carried + size > budget && stats.turns > 0) {
      stats.splits.push({ afterTurn: stats.turns, carried });
      close(); open();                    // fold the conversation and start a new one
    }
    const ev = await sendAndWait(job);
    carried = ev.carried_bytes ?? carried + size;
    stats.turns += 1;
    stats.peakCarried = Math.max(stats.peakCarried, carried);
  }
  close();
  return stats;
}

The readline layer is not decoration. A child process delivers stdout in chunks that do not align with line boundaries. Handing a raw data chunk straight to JSON.parse works while the output happens to land neatly and breaks the moment it does not. Let a dedicated reader assemble the lines from the start.

carried_bytes is whatever the session reports as its accumulated input. Since a real build may not use that exact field, the driver falls back to its own running total.

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 decide up front whether a long batch belongs in one conversation or several, using a rule you will not have to rebuild later
You will avoid the failure where a byte budget cuts a conversation in the middle of work that needed to stay consistent
You will be able to reproduce the measurement that showed 24 jobs accumulating to 43,854 bytes, and run it against your own batch
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.

or
Unlock all articles with Membership →
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.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $15 for lifetime access
View Membership →

Related Articles

Integrations2026-08-20
Find the one broken MCP entry before Antigravity starts, with 40 lines of Node
A single typo in your MCP settings can take every server down with it. Here is a small preflight script that catches the mistakes statically, the real output from a deliberately broken config, and the duplicate-key trap that JSON hides from you.
Integrations2026-07-19
When an Unresponsive MCP Server Freezes Your Agent: Separate Timeouts for Connect, List, and Call
Antigravity CLI 1.1.3 closed the case where an unresponsive MCP server stalls an agent forever, by adding timeouts to connect, list-tools, and call-tool. This walks through why the three boundaries fail differently, and builds a defensive wrapper with a circuit breaker and failure-only notifications, backed by working code and a week of overnight runs.
Integrations2026-07-18
Turning Silent Auto-Approvals into Allow Rules, One Soft-Deny at a Time
In Antigravity CLI 1.1.3, headless -p stops silently auto-approving confirmation-required tools and instead soft-denies them, printing the required allow-rule name to stderr. This piece uses that output as a discovery source to build least privilege from an empty allow set upward, with a working harness and real numbers from a personal automation.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →