◉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.

Antigravity411CLI8Automation8Operations14Scheduled 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

◈ 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 morning three-point check turned into a script — the model I have refined while running multiple products solo.
◉ Antigravity2026-10-31
Ask It to Only Read on Day One: A Five-Step Ladder for What to Hand Antigravity
What to ask Antigravity on your first day, and what to hold back. A five-step ladder that widens the agent's scope from least to most breakable, plus a small shell script that counts what actually changed before and after a session.
◉ Antigravity2026-10-28
When Antigravity Says Failed to Fetch: Decide the First Three Places to Look
A short triage order for Failed to Fetch in Antigravity: check whether the destination is reachable, whether you are still signed in, and whether a proxy or VPN sits in the middle. Includes copy-paste terminal checks and what each result means.