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 clean negative — you chose it, with
sys.fail(reason) |
2 |
Bad arguments — you chose it, or parse_args did on a
boundary violation |
3 |
The script didn't compile. Nothing ran |
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"; on
3–6 it never answered. See Exit status.
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:
- validates CLI + stdin,
- runs every
whererefinement, - fills defaults,
- is what
scua schematurns into JSON Schema for callers (MCPinputSchema, etc.).
RecordType is a type name, not a value:
write sys.parse_args(Args), never Args().
Sources of fields (CLI overlays stdin):
| Source | Shape |
|---|---|
| stdin | one JSON object (if non-empty) |
| CLI | --key value, --key=value, or
--flag (→ true) |
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. A
command line carries text and a JSON body carries JSON, so an enum field can be filled by
neither — take a string and convert it in your own
code (Mode.from_int, or a match over the
text). A collection field works from a JSON body but not from the
command line. scua schema describes these fields the way
JSON Schema would, so check that a caller can actually supply what you
declared.
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; nothing there changes the JSON-on-stdin
path, which still wins over anything on the command line.
#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) |
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.
#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 (where ranges, membership enums,
s.len() bounds, nested records via $defs).
Arbitrary where predicates stay authoritative in
SCUA; the schema says so in $comment and field
descriptions.
#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-envunless the tool needs them
See Read and write files and Read environment variables.