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.
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 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 outsideimport { 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.
I built 24 jobs — six apps times four languages. Each prompt measures 1,834 bytes. Then I ran them at different budgets.
Strategy
Conversations
Turns
Peak carried per conversation
No budget (one conversation)
1
24
43,854 bytes
Budget 40,000 bytes
2
24
38,374 bytes
Budget 20,000 bytes
3
24
18,290 bytes
Twenty-four jobs reach 43,854 bytes. That is 1,834 bytes stacking up with no sign of leveling off. At 240 jobs, a single conversation would be carrying roughly 430,000 bytes.
None of that surprised me. What mattered was where the budget chose to cut.
A byte budget cuts in the wrong place
At a 20,000-byte budget, the splits landed after turn 10 and after turn 20.
Split
Turn
On an app boundary (multiple of 4)?
Consequence
First
10
No
App 3's Japanese and English sat in one conversation; Korean and Traditional Chinese in another
Second
20
Yes
Aligned with the app boundary, so no harm
The jobs are ordered as four languages per app, so turn 10 is the second language of the third app. The Japanese and Korean notes for the same app were written in different conversations — the exact four items I had grouped together to keep consistent.
Bytes know nothing about the meaning of the work. A budget will happily cut wherever the arithmetic lands. I had assumed the splits would distribute evenly across the batch; in practice one of the two happened to fall on a boundary and the other did not. That was luck, not design.
Choose the boundary from the work, not the bytes
So I inverted it: open a new conversation only when crossing into a new app.
// split conversations on the unit of workconst byApp = new Map();for (const job of jobs) { const key = job.appId; // the scope that must stay consistent if (!byApp.has(key)) byApp.set(key, []); byApp.get(key).push(job.prompt);}let sessions = 0, peak = 0;for (const [, prompts] of byApp) { const s = await runBatch(prompts, { budgetBytes: Infinity }); // do not split on bytes sessions += s.sessions; peak = Math.max(peak, s.peakCarried);}
Lined up side by side, the trade is clear.
Strategy
Conversations
Peak carried
Consistency
One conversation
1
43,854 bytes
Across the whole batch
Byte budget 20,000
3
18,290 bytes
Cuts land unpredictably
App boundary
6
7,321 bytes
Within each app
One job per conversation
24
1,834 bytes
None
Splitting on app boundaries dropped the peak from 43,854 to 7,321 bytes — a sixth of the original — while guaranteeing that all four languages for an app share a conversation. The cost is six sessions instead of one.
The order of decisions I would recommend:
Name the scope that must stay consistent (here: "all languages for one app")
Fit that scope into a single conversation
Only if the scope itself exceeds what a conversation can hold, consider dividing the scope
Keep the byte budget as a last-resort safety valve, never above the scope
Reverse that order and the budget starts dividing your work for you. Having a ceiling is correct; having only a ceiling was my mistake.
Do not fire and forget
One more thing belongs in the driver. If you write all 24 lines to stdin without waiting, the run proceeds — but you lose the ability to stop it.
If the model misreads the instruction on job 3, jobs 4 through 24 are already queued. You find out when everything finishes, and the rerun costs all 24.
Waiting on sendAndWait lets you break out of the loop on the failing turn. A batch that stops halfway is itself a result worth having.
try { const ev = await sendAndWait(job); // validate the shape before sending the next one if (!ev.ok) throw new Error(`turn ${stats.turns + 1}: unexpected response shape`);} catch (err) { close(); console.error(`[batch] stopped after ${stats.turns} job(s): ${err.message}`); process.exitCode = 1; // do not report success break;}
Setting a non-zero exit code matters. CLI 1.1.14 fixed a case where the CLI exited silently after a language server failed at startup or mid-run; failures now surface in the exit code. That fix does you no good if the caller throws the value away. For more on non-interactive runs, see Running Antigravity CLI Non-Interactively: Design Before CI and cron.
Reproducing the numbers locally
You can validate driver behavior before wiring up the real binary. Write a receiver that emits a terminal event per input line, then swap it in through opts.cmd.
// mock-agy.mjs — the skeleton of stream-json I/O, nothing moreimport readline from "node:readline";let history = 0, turn = 0;readline.createInterface({ input: process.stdin }).on("line", (line) => { if (!line.trim()) return; let msg; try { msg = JSON.parse(line); } catch { process.stdout.write(JSON.stringify({ type: "error", reason: "invalid_json" }) + "\n"); return; } const text = msg?.message?.content?.map?.((c) => c.text ?? "").join("") ?? ""; history += Buffer.byteLength(text, "utf8"); turn += 1; // like the real thing: several lines per turn, then a terminal event process.stdout.write(JSON.stringify({ type: "assistant", turn, delta: "..." }) + "\n"); process.stdout.write(JSON.stringify({ type: "result", turn, ok: true, carried_bytes: history }) + "\n");});
Substitute your own jobs and one run tells you how far the accumulation goes and where the splits land. Switching to the real binary can wait until that shape is settled.
Where to start
Write down, in one sentence, the scope your batch needs to keep consistent. "All languages for one app" works. So does "all states of one screen." That sentence is your conversation boundary. The byte budget can come later, as the alarm that tells you the scope grew larger than you expected.
I got this order wrong the first time and rebuilt it. I hope this saves you that particular detour.
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.