ANTIGRAVITY LABJP
Articles/Antigravity Basics
Antigravity Basics/2026-08-10Advanced

Guarding the Turn Boundary in Non-Interactive Runs: A Command Table and a Fail-Closed Gate

A scheduled job meant only to record usage was calling the model on every run. Here is how I split commands into ones that start a turn and ones that do not, built a gate that refuses anything unclassified, and measured it against real scripts.

Antigravity350CLI5Automation8Operations11Scheduled Jobs

Premium Article

A scheduled job I wrote to record usage turned out to be driving that usage up.

When the graph started climbing, my first suspicion fell on other jobs. Lining up the timestamps told a different story: the increments landed neatly on the monitoring job's schedule. A command I had been treating as a read was starting a turn.

Non-interactive runs let this kind of mistake pile up quietly. When you type a command yourself, a turn starting is visible on screen. A scheduled run gives you none of that feedback.

As an indie developer leaning more and more on scheduled execution, that blind spot compounds. What follows is how I moved this boundary out of my memory and into something a machine enforces.

Write the turn boundary down as a table

In print mode (-p), read-only slash commands answer without starting a turn, while interaction-only commands are explicitly rejected. In practice that splits commands three ways.

ClassBehavior in a non-interactive runSafe from a monitoring job?
readonlyAnswers without starting a turnYes
turnStarts a turn (model call, side effects)No
interactiveRejected as interaction-onlyCannot work at all

The important part is not to run on the assumption that you know this split. I thought I did, and I was wrong. Human memory does not keep up as the command surface grows.

So it goes into a table that code can read.

Refuse anything unclassified: the fail-closed gate

Owning a table is not enough. What you do with a command that is missing from it determines the character of the whole design.

I went fail-closed. Unclassified means the command does not run and the process exits non-zero. Having a job break when a new command appears is annoying, but a broken job is far easier to deal with than a silent charge.

#!/usr/bin/env python3
"""Turn-boundary gate for non-interactive runs.
Anything missing from the table is refused rather than executed."""
import re, shlex, subprocess, sys
 
READONLY = {"usage", "status", "model", "help", "cost", "context", "mcp", "config"}
TURN     = {"init", "review", "compact", "run", "agent", "fix"}
INTERACTIVE_ONLY = {"settings", "login", "logout", "clear", "resume", "quit"}
 
EXIT_UNCLASSIFIED = 78   # not in the table: do not run
EXIT_WOULD_SPEND  = 79   # starts a turn: not allowed from a monitoring job
EXIT_INTERACTIVE  = 80   # interaction-only: cannot work here
 
_norm_re = re.compile(r'^/?([a-z][a-z0-9_-]*)')
 
def normalize(raw):
    """Reduce '/usage --json', ' /Usage', '/mcp:list' to a canonical name.
    Only the first token is inspected. Anything unparseable returns None."""
    if raw is None:
        return None
    s = raw.strip()
    if not s:
        return None
    try:
        head = shlex.split(s)[0]
    except ValueError:
        return None                      # unbalanced quotes: refuse as-is
    head = head.split(':', 1)[0].lower()  # /mcp:list -> mcp
    m = _norm_re.match(head)
    return m.group(1) if m else None
 
def classify(raw):
    name = normalize(raw)
    if name is None:
        return "unclassified", None
    if name in READONLY:
        return "readonly", name
    if name in TURN:
        return "turn", name
    if name in INTERACTIVE_ONLY:
        return "interactive", name
    return "unclassified", name
 
def run_readonly(raw, binary="antigravity", timeout=60):
    kind, name = classify(raw)
    if kind == "turn":
        sys.stderr.write(f"[turn-gate] '{name}' would spend a turn; refusing\n")
        return EXIT_WOULD_SPEND, None
    if kind == "interactive":
        sys.stderr.write(f"[turn-gate] '{name}' is interaction-only\n")
        return EXIT_INTERACTIVE, None
    if kind == "unclassified":
        sys.stderr.write(f"[turn-gate] unclassified: {raw!r}; add it to the table first\n")
        return EXIT_UNCLASSIFIED, None
    cmd = [binary, "-p", f"/{name}", "--output-format", "json"]
    try:
        p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
    except FileNotFoundError:
        sys.stderr.write(f"[turn-gate] {binary} not found\n")
        return 127, None
    except subprocess.TimeoutExpired:
        sys.stderr.write(f"[turn-gate] timed out after {timeout}s\n")
        return 124, None
    return p.returncode, p.stdout
 
if __name__ == "__main__":
    code, out = run_readonly(sys.argv[1] if len(sys.argv) > 1 else "")
    if out:
        sys.stdout.write(out)
    sys.exit(code)

Three decisions in that code are worth spelling out.

Three distinct exit codes

78, 79, and 80 are separate because they mean different things once you start aggregating logs.

A 78 means your table is stale. A 79 means the job itself is written wrong. An 80 means you are trying to force something interactive into a place it cannot live. Collapsing three different causes into one exit code leaves you with logs that tell you nothing about what to fix.

Normalize only the first token

If you try to interpret flags like --output-format json or arguments like /config get model, your classifier ends up chasing the full command specification forever.

Deciding the turn boundary needs only the leading name. Refusing to look further is a deliberate constraint, not an oversight.

Treat broken quoting as unclassified

shlex.split raises ValueError on an unbalanced quote. Swallowing that exception and assuming the command is probably read-only would hollow out the whole fail-closed premise.

Broken input stays broken and gets refused. That turned out to be the single most useful rule in practice.

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
A three-way classification of slash commands plus a fail-closed gate that refuses to run anything not in the table, with the full implementation
Scanning 1,430 command invocations across 20 production shell scripts showed 58.1% build their argument strings at runtime, which is why static auditing alone falls short
Comparing a naive exact-match table against a normalizing classifier over 29 cases revealed that its errors skew toward over-blocking rather than unsafe misclassification, the opposite of what I expected
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 $10 for lifetime access
View Membership →

Related Articles

Antigravity2026-06-24
Antigravity 2.0, CLI, IDE, SDK — Weaving All Four Surfaces Through a Real Project
Antigravity ships as a desktop app, a CLI, an IDE, and a Python SDK. Beyond picking one, this guide shows how to weave all four across a single project — with a headless-execution wrapper for automation, plus the cost and migration traps to sidestep.
Antigravity2026-06-12
Six Days Until Gemini CLI Shuts Down — Auditing Automation Dependencies and Migrating to Antigravity CLI
With Gemini CLI ending on June 18, here is a practical walkthrough for finding gemini command dependencies hiding in cron, CI, and shell scripts, then migrating and verifying them on Antigravity CLI.
Agents & Manager2026-04-27
Letting Antigravity Be Your Night-Shift Engineer: A Solo Dev Operating Model
How to operate Antigravity agents as the second engineer who works while you sleep. Task hand-off, scope boundaries, and a five-minute morning review — the model I have refined while running multiple products solo.
📚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 →