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 · 36 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

02 are yours to choose; scua reserves 36 for its own outcomes and never uses 02 for them. The line that matters: on 1 the tool ran and answered "no"; on 36 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:

  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 (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-env unless the tool needs them

See Read and write files and Read environment variables.