SCUA

How-to

Write tools for AI agents

SCUA is a strong target for one-shot tools an agent (or a shell harness) generates and runs: default-deny I/O, fuel/memory kill-switches, exact money, and a predictable stdout / stderr / exit contract. This page is the full shape of that contract.

For plain positional args without a tool envelope, see Read command-line arguments.

#The contract

Channel Role
stdin JSON payload (or empty)
stdout Exactly one JSON result
stderr Diagnostics only
exit status 0 success · 1 clean negative · 2 usage / bad args · 3–6 the run itself failed

Statuses in full, because a caller usually branches on this before it reads anything:

Status Meaning
0 Success — you chose it, with sys.emit or sys.exit(0)
1 A failure you chose to report, with sys.fail(reason), or ? or return Error(e) at the top level of the script: often a clean negative, but it can be a missing file or a bug. Read error to tell which
2 Bad arguments — you chose it, or parse_args did on a boundary violation
3 Nothing ran: the script didn't compile, --deny-warnings found a compile warning, or the project needs a newer scua
4 The script wanted a capability the run wasn't granted. Nothing ran — re-run with the --allow-… it names
5 The script crashed (an uncaught fault)
6 --max-ops or --max-mem stopped the run

0–2 are yours to choose; scua reserves 3–6 for its own outcomes and never uses 0–2 for them. The line that matters: on 1 the tool ran and answered "no", or reported a failure of its own, and {"error": …} on stderr says which; on 3–6 it never answered. See Exit status.

A harness that runs scripts an agent wrote can pass --deny-warnings, so a compile warning (a call that can never do what it says, for instance) is refused with status 3 before the script runs, alongside the compile errors, instead of being left in stderr for nobody to read. A harness that runs scripts it didn't write and only needs to know whether they worked can go the other way and pass --no-warnings, which keeps compile warnings out of the stderr it reads; errors still print. Pass one or the other, not both.

import sys

record Args {
  a: int where a in 0:1000000,
  b: int where b in 0:1000000,
}

let args = sys.parse_args(Args)
sys.emit({ ok = true, sum = args.a + args.b })
$ echo '{"a":2,"b":3}' | scua tool.scua
{"ok":true,"sum":5}

$ scua tool.scua --a 2 --b 3
{"ok":true,"sum":5}

$ scua tool.scua --a 1
{"error":{…missing field "b"…}}   # exit 2

Runnable end-to-end: examples/agent_tool.scua.

#Building the argument record

sys.parse_args(RecordType)

Declares the tool's interface once. The same record:

  1. validates CLI + stdin,
  2. runs every where refinement,
  3. fills defaults,
  4. is what scua schema turns into JSON Schema for callers (MCP inputSchema, etc.).

RecordType is a type name, not a value: write sys.parse_args(Args), never Args().

Sources of fields, highest priority first:

Source Shape
CLI flags --key value, --key=value, or --flag (→ true)
stdin one JSON object (if one arrives; see below)
CLI positionals bare words, filling the remaining fields in declaration order
defaults the record's = value for any field still unset

A flag always wins over the same field in a JSON body, and a JSON body wins over a positional.

A boundary violation (missing required field, wrong type, failed where) never runs the tool body — it calls sys.fail(…, 2). "Wrong type" includes the declared shape: a field declared { string } must be given a JSON array of strings — a single word on the command line is rejected, not accepted as a one-word-long list of characters.

That check is worth knowing when you choose the field's type. An enum field takes the variant's name or its number, from the command line (--mode Fast or --mode 0) or a JSON body ("mode": "Fast" or "mode": 0); a name or number the enum doesn't have is refused, listing the ones it does. A flags field takes the bitmask (--opt 3), or in a JSON body a list of member names ("opt": ["A", "B"]). A collection field works from a JSON body but not from the command line. scua schema describes each of these the way JSON Schema would, so check that a caller can actually supply what you declared.

#When stdin is read

parse_args looks at stdin only when a JSON body could still supply something: when the command line passed nothing at all, or left a field with no default unfilled. Even then it reads a body only when one is actually there:

stdin is parse_args
a file (mytool < body.json) reads it
a pipe that sends data or closes within 100 ms (echo '{…}' | mytool) reads it, to the end
a pipe that stays open and silent uses the defaults (or refuses a missing required field)
a terminal uses the defaults: a person running the tool by hand is never left waiting

So a harness that starts a tool with stdin attached and never writes to it, or never closes it, gets the defaults rather than a tool that waits forever. The cost is the other side of the same rule: a writer that takes longer than 100 ms to start sending is ignored. When a body may be slow to arrive, don't rely on the pipe: write it to a file and redirect it (mytool < body.json), or pass the fields as arguments. Once a body starts arriving, it is read to the end however long that takes.

sys.stdin() is different: it asks for stdin outright, so it always reads to the end of input.

parse_args also handles the things an ordinary command line has and an agent tool doesn't — positional arguments, short flags, and a generated --help. See Read command-line arguments. The priority order above holds there too: a flag beats the JSON body, and the JSON body beats a positional.

#Lower-level pieces

Call Use when
sys.stdin() You want the raw UTF-8 payload yourself
sys.args() Positional strings only
sys.args_table() Named flags as a loose table, no record type

#Emitting a result or a failure

Call stdout stderr exit
sys.emit(value) one JSON value (via json.encode) — 0
sys.fail(reason) — {"error": …} 1
sys.fail(reason, code) — {"error": …} code (0–255)
? on an Error(e), or return Error(e), at the script's top level — {"error": e} 1
sys.exit(code) flushes prior prints — code
sys.write(s) s, with no newline, immediately — — (doesn't exit)
sys.write_err(s) — s, no newline, no prefix — (doesn't exit)

sys.emit exits. Code after it never runs. For streaming / multi-line output use print and a final sys.exit.

The table is for the top level of the script. Called in a partition's handler, all three end that handler's turn rather than the program, which runs on to its end. sys.fail (or sys.exit with a non-zero code) in a handler still fails the run: when it ends, it exits with that code and writes the {"error": …} line, so a tool whose work runs in a partition reports failure the same way. See sys.exit, sys.emit and sys.fail in a handler.

#Keeping stdout parseable

The channels are already split for you. print and sys.write go to stdout; log statements (info, warn, severe) and sys.write_err go to stderr. So logging in a tool never corrupts what it returns, and you don't have to arrange anything for that to be true.

What does share stdout is print and the result. A normal run streams print output as it happens, so by the time sys.emit writes the result the earlier lines have already gone — they can't be taken back, and the caller gets two things where it expected one:

progress line
{"ok":true}

Two ways out, and the second is the one to reach for when something is parsing you.

Print only what you want parsed. Progress and status belong on stderr. sys.write_err is the plain one — raw bytes, no newline, no level prefix, exactly like sys.write but on the other stream:

sys.write_err("working... ")

Or declare the output mode, with --output=json. Then stdout is the result channel and nothing else reaches it — print, sys.write and log lines all go to stderr — so a tool that prints is still safe to parse:

$ scua --output=json tool.scua < in.json
{"ok":true,"sum":5}

Nothing is discarded; the printed lines are on stderr. It's a flag on the run rather than on the script, because the caller parsing stdout as JSON already knows it wants JSON, and the script never has to know how it's being read.

sys.fail is the one diagnostic that always reaches stderr on its own — it writes there and exits.

Dropping a key so it doesn't encode as null. json.encode renders a key whose value is nil as "key":null, and most APIs reject that with a 400. Setting the value to nil doesn't help — the key is still there. delete(t, k) removes it, so the field is simply absent:

import json
let body = {}
body["name"] = "ed"
body["nickname"] = nil
print(json.encode(body))
delete(body, "nickname")
print(json.encode(body))
$ scua omit.scua
{"name":"ed","nickname":null}
{"name":"ed"}

decimal and money encode as lossless strings (never JSON numbers):

import sys
sys.emit({ total = 19.99 USD })
$ scua tool.scua
{"total":"19.99 USD"}

A fault (bug) is different from sys.fail: faults print a traceback and are for the author, not a chosen tool outcome.

#JSON Schema for callers

$ scua schema tool.scua --type Args

Emits JSON Schema draft 2020-12 for the decidable subset of the record:

  • a range, x in a:b (minimum/exclusiveMaximum);
  • a comparison with a constant, days > 0 or score <= 1.5, either way round (minimum, exclusiveMinimum, maximum, exclusiveMaximum);
  • a length, s.len() in a:b or s.len() >= 1 (minLength/maxLength on text, minItems/maxItems on a list);
  • an and of those;
  • membership enums, and nested records via $defs.

Any other where predicate stays authoritative in SCUA; the schema says so in $comment and the field's description. Give the where a reason (where n % 2 == 1 else "must be odd") and the reason is the description instead, so a caller learns the rule even when the schema can't express it.

#Teaching a model SCUA

$ scua pack --repo=. --tier=compact --out=-
$ scua pack --repo=. --tier=both

Builds a markdown teach-pack: compact grammar, the I/O paragraph above, collapsing primitives from the stdlib, few-shots from examples/, reserved words, and common pitfalls. Token estimates go to stderr. Prefer compact in a system prompt; regenerate after language changes.

#Sandbox the run

Agent-generated code should not get ambient authority:

$ scua --max-ops=10m --max-mem=64M --allow-fs=./scratch tool.scua < in.json
  • --max-ops / --max-mem — kill-switches the script cannot disarm
  • --allow-fs=DIR — rooted filesystem only (.. cannot climb out)
  • no --allow-net / --allow-env unless the tool needs them

See Read and write files and Read environment variables.