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:
- 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, 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 > 0orscore <= 1.5, either way round (minimum,exclusiveMinimum,maximum,exclusiveMaximum); - a length,
s.len() in a:bors.len() >= 1(minLength/maxLengthon text,minItems/maxItemson a list); - an
andof 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-envunless the tool needs them
See Read and write files and Read environment variables.