SCUA

Manual

Command-line interface

The scua binary runs a script, scaffolds a new project, formats source, runs tests, and steps a program under a debugger. Run it with no arguments (or --help) to see the authoritative usage:

$ scua
usage: scua [options] <file.scua> [args...]
       (options precede the file; everything after the file — or after `--` — is passed to the
        script and read with `sys.args()`)
       scua init [name]                 (scaffold a new project in ./name, or the current dir)
       scua init compact [name]         (bare-bones: just a runnable main.scua)
       scua init game [name]            (a small game that saves the player's progress)
       scua init zed-debug|vscode-debug (add debug config to the current project)
       scua fmt [--check] [--fix] <file.scua> (indentation + trailing whitespace; --fix: as(…) → parse(…))
       scua test [dir|file]             (run *_test.scua files, or just the one named)
       scua res build DIR -o FILE | list FILE | check FILE  (resource packs: res: paths)
       scua user path ID [ID…]           (where --allow-user=ID keeps a program's saves; creates nothing)
       scua user adopt ID [--yes]        (use an existing folder scua did not make as ID's)
       scua schema <file.scua> --type T (emit JSON Schema for a record/enum type)
       scua blob-info [--keys FILE] <file|dir>  (what a saved actor or session holds; runs nothing)
       scua pack [--tier=…] [--repo=DIR] (teach-a-model in-context pack)
       scua cluster dump <file.scdb>    (inspect a durable shared-config snapshot)
       scua debug <file.scua>           (run under the terminal stepper)
       scua add|publish|vendor|…        (package verbs; needs scua-pkg on PATH — `scua-pkg --help`)
       scua --version | --help | --license

options:
  --fast                         fast-forward the clock (instant; for demos/sims)
  --dump-bytecode[=verbose]      print the compiled bytecode (annotated) and exit; don't run
  --log=<trace|debug|info|warn|severe>
                                 drop log statements below this level at compile time
  --log-time                     prefix log lines with an ISO-8601 timestamp
  --log-stdout                   send log lines to stdout, mixed in with the script's own
                                 output (they go to stderr by default, as diagnostics)
  --output=<text|json>           text (default): stdout is what the script prints. json: stdout
                                 is ONLY the `sys.emit` result; print/log go to stderr
  --jit[=on|always|off]          the JIT: ON by default; --jit=off runs the interpreter only
  --jit-stats                    print JIT telemetry (compiles/entries/deopts) to stderr on exit
  -D name[=value]                set a `build` flag for `comptime if` (bare = true)
  --profile <name>               select a build profile from scua.toml (else its `default`)
  --mod-path DIR                 add a module search directory (repeatable; tried after the entry dir)
  --allow-file=PATH              read capability grants from a TOML file (a [capabilities] table, or a
                                 bare table): fs/net/env/serve/cluster/udp/tty/sandbox, each `true`
                                 for the bare form, a string/list for the constrained one, or
                                 `false`/`[]` to refuse it outright; `exec` (a list of commands, such
                                 as [["git", "status"]]) and `exec_any`; `user`/`user-dir` for the
                                 per-user folder; `mount`. A --allow-* flag below always outranks
                                 it. Its exec list and user keys are refused when the file sits in
                                 a folder the program may write.
  --allow-all                    grant EVERY capability, each in its broadest (bare-flag) form —
                                 fs at the cwd, any host, any bind, any peer, every env var, the
                                 terminal. For a script you already trust; a narrower --allow-* flag
                                 still wins. Never the per-user folder, which needs --allow-user=ID.
  --allow-fs[=DIR]               grant `fs` (read/write/delete) rooted at DIR (default: cwd)
  --allow-user=ID                grant the per-user folder for app ID (its saves and settings, as
                                 user: paths): Roaming, Application Support or XDG_DATA_HOME. ID is
                                 three or more lowercase parts, such as dev.example.mytool
  --user-dir=PATH                grant the folder at PATH as the per-user folder (relative to the
                                 program's folder), e.g. --user-dir=save while developing
  --user-adopt                   for a shipped launcher: use an existing folder for the ID that
                                 scua did not make (a cloud restore on a second machine)
  --mount=PATH                   add a read-only res: layer: a pack, a directory, or DIR/*.pak
                                 (repeatable; a later one wins; relative to the program's folder;
                                 PACK#sha256=HEX pins it). Above the project's res/ folder
  --res=PATH|none                the bottom res: layer in place of the project's res/ folder
  --show-res                     print the resource view to stderr, then run
  --res-missing=allow            revive a save although a res: layer it ran with is not mounted
  --strict-res                   refuse to revive a save whose res: layers changed since it was made
  --allow-net[=hosts]            grant the `net` capability (HTTP); comma-separated host allowlist
  --allow-env[=NAMES]            grant the `env` capability; comma-separated name allowlist (bare = all)
  --env-file=PATH                load a `.env` file and grant `env` for exactly the names in it
                                 (repeatable, applied in order). A real environment variable wins
                                 over a file one, and an earlier file over a later one. Without
                                 --allow-env the process environment stays hidden.
  --allow-serve[=ADDR:PORT]      grant the `serve` capability (HTTP/TCP LISTEN) constrained to that bind;
                                 bare --allow-serve = any bind (trusted hosts only). Never implied by --allow-net.
  --allow-cluster[=SECRET]       grant the `cluster` capability (the `shared` config table); SECRET is
                                 the cluster's shared MAC secret. Bare = empty (demo/trusted).
  --allow-udp[=PEERS]            grant the `udp` capability (DATAGRAMS); comma-separated allowlist of
                                 ADDR[:PORT] or CIDR[:PORT] (e.g. 1.1.1.1:53, 10.0.0.0/8:7946).
                                 bare --allow-udp = any peer (trusted hosts only). `udp.bind` also needs
                                 --allow-serve. Never implied by --allow-net: datagrams can amplify.
  --udp-max-payload=N            raise the datagram payload cap from the 1200-byte portable-safe default
  --allow-tty                    grant the `tty` capability (prompt the person at the keyboard):
                                 sys.readline and sys.readpassword. Separate from piped stdin, which
                                 sys.stdin() reads with no grant at all.
  --allow-exec                   grant `exec` for the commands listed under [exec] in the project's
                                 scua.toml: exec.run and exec.spawn run those and nothing else
  --allow-exec-any               grant `exec` in its open tier: run any command (exec.run_any,
                                 exec.spawn_any, and exec.run). A child runs as you, so this is
                                 delegation, not containment; --allow-all does not grant it
  --io=async                     let capability I/O (fs, http, exec, net) park instead of waiting in
                                 place, so other tasks and partitions run meanwhile; default: blocking
  --net-max-parked=N             --io=async: net calls one partition may have waiting (default 1024)
  --net-max-queued-write=BYTES   --io=async: unsent bytes parked writes may hold per connection
                                 (default 16777216)
  --net-max-registrations=N      sockets watched at once: served connections and waiting net/http calls (default 16384)
  --http-max-connections=N       --io=async: connections http calls may hold open to one host and
                                 port, kept between calls; past it a call waits (default 256)
  --blob-keys=FILE               sign every actor snapshot with this deployment's key and revive
                                 only blobs that verify (HMAC-SHA256). FILE holds one key per line
                                 as 64 hex digits, the one to sign with followed by `sign`; the
                                 others still verify, so keys rotate. Refused when FILE is inside
                                 a directory --allow-fs grants, or beside --allow-exec-any: no
                                 script may read it
  --accept-unkeyed-blobs         with --blob-keys, also revive blobs saved without a key
  --allow-fork                   let the program fork actors: `actors.revive(blob, { fork = true })`
                                 starts a second live copy of a saved partition under a new
                                 identity. Off by default; --allow-all does not grant it
  --strict-constants             refuse a revive whose actor holds a value from a constant that
                                 is gone or changed shape, rather than keep its stored copy
  --serve-debug                  put located fault text in http.serve 500 bodies (local dev only)
  --serve-check                  run to the first http.serve, apply its start-up checks, exit unbound
  --serve-timeout=DUR            deadline for one served request, from its first byte (default 30s)
  --serve-header-timeout=DUR     request head within this of its first byte (default 10s)
  --serve-body-timeout=DUR       request body within this of the head (default 30s; 1 KiB/s after 10s)
  --serve-write-timeout=DUR      close a response the client stops taking (default 30s)
  --serve-idle-timeout=DUR       close an idle kept-alive connection (default 75s)
  --serve-max-conns=N            connections at once (default 10000, or the descriptor limit)
  --serve-max-buffered=BYTES     part-received request bytes across connections (default 64M)
  --serve-drain-delay=DUR        on a stop, keep answering this long first (default 0)
  --serve-grace=DUR              then wait this long for requests in flight (default 25s)
  --serve-stats=DUR              a line of request counts and latency every DUR
  --serve-stats-every=N          every N requests, arena bytes per request and GC rate
  --serve-log                    one JSON line per request on stderr
`--http-timeout=MS` — the whole-exchange deadline for `http` requests, in milliseconds (default `120000`; `0` = no deadline). A per-call `timeout_ms` may tighten this but never loosen it, so an operator can bound a script without editing it. Under `--io=async` the deadline also covers the wait for a free connection.

`--http-max-connections=N` — under `--io=async`, how many connections `http` calls may hold open to one host and port (default `256`). They are kept between calls and reused; a call that finds all of them busy waits for one, within its own deadline. Raise it for a program that keeps more calls than that in flight against one fast service; lower it to cap what a program can ask of a service.

  --locale=<tag|host>            set sys.locale() (BCP-47, e.g. de-DE; `host` reads the device)
  --tz=<±HH:MM|host>             set sys.timezone() offset (e.g. +02:00, +05:45; `host` reads the device)
  --max-ops=N                    stop a turn at its Nth step — one function call or one loop
                                 iteration each (suffix k/m/g); kills on exhaustion (without it
                                 there is no CPU limit — the built-in ceiling refills)
  --max-total-ops=N              stop the run once it has taken N steps in all, across every turn
                                 and every `wait` (suffix k/m/g), or `none`
  --max-mem=BYTES                cap the script's extra live memory (suffix k/m/g, e.g. 64M)
  --mem-stats                    print partition memory telemetry to stderr on exit (diagnostic)
  --gc-threshold=BYTES           set the GC pacing floor (default 8M; suffix k/m/g) — diagnostic

scua init, scua fmt, scua test, and scua debug are subcommands (they take the place of <file.scua>); --version (or -V) prints the version and exits. Each subcommand and option is detailed below.

A released build prints just the version — scua 0.26.0. A build that was not compiled for speed names itself, because a number measured on one is a number about the build:

$ scua --version
scua 0.26.0-dev.abc1234 (debug — unoptimised; do not benchmark)

You will only see that if you built from source without -Doptimize=ReleaseFast. A debug build is correct — it runs the same programs and gives the same answers — but it is roughly a hundred times slower on work that allocates, so timings and memory figures taken from one describe the compiler's settings rather than your code.

scua --help includes Copyright © 2026 Unabated Games Ltd. All rights reserved. scua --license prints the full pre-release evaluation licence and confidentiality agreement to stdout and exits successfully. The terms are embedded in the binary, so no separate licence file is needed to display them. These notices are not printed during normal script execution. Like --help and --version, --license after the script path (or after --) is passed to the script.

#Running a file

$ scua file.scua

This compiles and runs the script. With no flags, timers run in real time: a script that waits five seconds takes five seconds. The options below change that and a few other things.

Before anything runs, the checker reports every problem it finds, one line each on stderr: the errors in file and line order, then the warnings. A warning doesn't stop the run; an error does, with status 3. After 50 lines, one more line counts the rest:

$ scua report.scua
scua: report.scua:4: type error: value is string, but the binding is declared int
scua: report.scua:9: type error: argument 1 to 'restore' is string, expected number
scua: report.scua:6: warning: `tonumber` is given an array ({ int }), which is never a number, so this always gives nil — pass it a string or a number

A warning that one of your names hides a built-in is the exception to "before anything runs". It can never stop a run, and a new SCUA release that adds a built-in can raise one in a program nobody changed, so it's printed when the program has finished: after its output, its error or its sys.fail reason. The first line on stderr is then always about what your program did. When the program is refused, it follows the errors with the other warnings.

--deny-warnings and a file's --!strict make warnings stop the run too, and the warnings of the packages you depend on are quiet unless you pass --dep-warnings. --no-warnings, or warnings = false under [run] in a project's scua.toml, keeps them off stderr.

If the file can't be read, scua exits with status 2 (a bad argument — see Exit status below) and an error like:

scua: missing.scua: cannot read file

A word with no . or / in it that names no file is taken for a command that doesn't exist. The error lists the commands, and names the nearest one when you're a letter or two away:

$ scua tset
scua: `tset` is not a command, and there is no file of that name. Did you mean `scua test`? The commands are init, fmt, test, debug, schema, pack, cluster, serve, blob-info, res and user, and the package verbs `scua --help` lists

A script whose file really is called check still runs as scua check.

An option scua does not know is reported as one, with the nearest option it does, and exits 2:

$ scua --sandbox agent.scua
scua: unknown option `--sandbox`. Did you mean `--allow-sandbox`? `scua --help` lists the options; scua's own options go before the script file

#Passing arguments to the script

$ scua [scua-options] <file.scua> [program-arguments...]

scua's own options come before the file. Everything after the file name is passed to the program, verbatim — including tokens that look like flags — and read with sys.args():

$ scua --fast game.scua  level3  --hard       # sys.args() == ["level3", "--hard"]

Use a bare -- before the file to stop scua's option parsing early (e.g. for a file or argument that begins with -). A program argument that isn't valid UTF-8 fails the launch (a SCUA string must be UTF-8). See Read command-line arguments.

#Running an application bundle

$ scua [scua-options] <app.scuapp> [program-arguments...]

An application bundle is a whole tool in one file: its own .scua files, the packages its lock resolved, and a digest for each, written by scua-pkg bundle --app. scua recognises one by its first bytes, checks all of it before anything compiles, and runs its entry script. Options and arguments work as for a .scua file, with these differences:

  • Capabilities still come only from this command line. A run missing a grant is refused before it starts, as for a .scua, and the message names every grant the bundle says it needs (status 4).
  • A bundle that fails a check is refused with an E-APP-FORMAT or E-APP-CORRUPT code, or the lock and record codes deps/ uses, and nothing runs (status 3). The first check is a SHA-256 over the whole file. Bundles from scua-pkg 0.3.19 are an older format and are refused: rebuild them with a newer scua-pkg bundle --app.
  • --mod-path, --mod-override, --profile and debug are refused with a bundle (status 2): each would bring in files or settings from beside it.

See Ship a tool as one file.

#A project that needs a newer scua

A project can name the oldest scua it runs on, with scua-version under [package] in its scua.toml, and so can each package in its lock (see Requiring a minimum scua). Running a file, scua debug, scua test and an application bundle all check it first, before the script is even parsed. A scua older than any of those refuses with status 3:

scua: @scua/jwt 0.7.0 needs scua 0.31 or later; this is 0.30.2. Upgrade scua, or pin @scua/jwt to an earlier version.
scua: this project needs scua 0.31 or later (scua-version in its scua.toml); this is 0.30.2. Upgrade scua.
scua: tool.scuapp needs scua 0.31 or later; this is 0.30.2. Upgrade scua.

A scua built from source is let through for a requirement up to its next minor version, with a note on stderr. A script with no scua.toml above it is never checked.

#Exit status

A program driving scua can tell what happened from the status alone, without reading stderr.

Status Meaning Who chose it
0 Success your script (or a clean finish)
1 Your script stopped on an Error it chose to pass up: often a clean negative ("the thing you asked about isn't there"), but it can be any failure, a missing file, a corrupt config, or a bug. {"error": …} on stderr says which your script, via sys.fail(reason) or sys.exit(1) (at the top level, or in a handler, where the run finishes first), or ? or return Error(e) at its top level
2 Bad arguments, including a capability file scua refuses and a per-user folder the run cannot use (an E-USER-… code first on the line, except E-USER-LOCKED, which is 9) scua, or your script via sys.fail(reason, 2)
3 Nothing ran: the script didn't compile (a syntax, type or checker error), a compile warning in your own files stopped it under --deny-warnings or in a --!strict file, or the project needs a newer scua scua
4 The script asked for a capability this run wasn't granted. Nothing ran, except for a user: path: a program that uses one runs without the per-user folder and stops at the first user: call it reaches scua
5 The script crashed — an uncaught fault, in the main body or an actor scua
6 A kill-switch stopped the run — --max-ops, --max-total-ops or --max-mem scua
7 The runtime itself could not continue — you should not see this one scua
8 Under --serve-check, the program ended without starting a server: it never called http.serve, or carried on past an Error it returned scua
9 The per-user folder is in use by another run (E-USER-LOCKED). Nothing ran; the launch was right, so trying again later, or launching every run with --user-shared, is the fix scua
75 A server was stopped (Ctrl-C or SIGTERM) and its grace period ran out with requests still in flight; they were abandoned. See --serve-grace scua

The split that matters for a tool: 1 means the script ran and reported a failure it chose to report, 3–8 mean it never produced an answer at all (a 4 from a user: call may have printed some output first). The error in sys.fail's output is any JSON value the script gave it, so read it rather than assuming 1 means "not found". A standard-library Error written out this way is {"kind": …, "message": …}, and a kind of "misuse" means a bug in the script, not a "no". Every way to exit 3 is decided before the first line of the script runs. 4 in particular is worth handling separately — the script is fine and the launch is what has to change, so the fix is a --allow-… grant rather than an edit.

0, 1 and 2 are yours to choose. They're the statuses sys.exit and sys.fail are meant to choose between (see Write tools for AI agents). scua itself uses 2 only for a launch it refuses before any of the script runs (bad arguments, a capability file it refuses, a per-user folder the run cannot use, such as E-USER-ID or E-USER-MISMATCH), the usual meaning of 2 for a command-line tool. It reserves 3–9 for its other failures. Keep your own choices in 0–2, and the only overlap is a 2 that means "this launch was wrong", whoever said so. sys.exit accepts any value 0–255, so nothing stops a script picking 5 — it just makes its own result indistinguishable from a crash.

scua test and scua fmt --check are separate: both exit 1 when they find something (a failing test, an unformatted file). Under scua test, a test file that doesn't compile, or that warns under --deny-warnings, fails like a failing test.

#scua init [name]

Scaffold a new project. With a name, scua init creates that directory and fills it; with no name, it scaffolds into the current directory:

$ scua init myproj
Created a new SCUA project in ./myproj:
  myproj/main.scua
  myproj/greet.scua
  myproj/main_test.scua
  myproj/scua.toml
  myproj/README.md
  myproj/.gitignore

Next steps:
  cd myproj
  scua main.scua        # -> hello, world
  scua test

The generated main.scua reads command-line arguments with sys.args() and prints a greeting, so the project runs straight away:

$ cd myproj
$ scua main.scua          # -> hello, world
$ scua main.scua Ada      # -> hello, Ada
$ scua test               # 2 passed, 0 failed (1 file)

The greeting itself lives in a small greet.scua module, which is what main_test.scua imports and tests — main.scua just wires arguments to it. scua.toml is a starter project manifest with debug and release build profiles (see the --profile option below).

scua init never overwrites anything: if the target directory already exists, or any file it would create is already present, it stops with an error and writes nothing.

scua init compact [name]

The bare-bones cut — a single runnable main.scua and nothing else (no module split, test, manifest, or README). Use it when you just want one file to start from:

$ scua init compact myproj
Created a bare-bones SCUA project in ./myproj:
  myproj/main.scua

Next steps:
  cd myproj
  scua main.scua        # -> hello, world

The generated main.scua is a one-line program that prints hello, world. Like scua init, it accepts an optional name (creates that directory) or scaffolds into the current directory, and never overwrites an existing file.

#scua init game [name]

A small game that keeps the player's progress, set up the safe way from the start:

$ scua init game tinykeep
Created a new SCUA game in ./tinykeep:
  tinykeep/main.scua
  tinykeep/progress.scua
  tinykeep/progress_test.scua
  tinykeep/scua.toml
  tinykeep/README.md
  tinykeep/.gitignore

Next steps:
  cd tinykeep
  scua --user-dir=saves main.scua    # play a round; run it again and the gold carries on
  scua test

Before shipping, change the app id in scua.toml (dev.example.tinykeep) to a reversed domain of your own.

progress.scua holds the save: a Save record, load() with json.decode(text, Save) (so a damaged or mistyped save is an Error, and a new game only when there is no save), and save(s) with json.encode(s, Save), which checks the whole value before anything is written, then a temporary file renamed over the old one. progress_test.scua saves a representative state the way the game does, so a change that lets a field hold something with no saved form fails a test, not a player's save. scua.toml declares the app id, dev.example. and the project name; the game's folder is named after it (see Save the player's progress). Like scua init, it never overwrites a file.

scua init zed-debug / scua init vscode-debug

Add a debugger configuration to an existing project so you can step through a .scua file in your editor — zed-debug writes .zed/debug.json, vscode-debug writes .vscode/launch.json. Both target the scua debug adapter the SCUA editor extensions register, which needs the scua-dap binary on your PATH.

$ scua init zed-debug
Wrote .zed/debug.json — SCUA debug configurations for Zed.
Debugging needs `scua-dap` on your PATH — build it with `zig build dap`.

Each writes two launch configurations: one that always debugs main.scua, and one that debugs the currently open file. They operate on the current directory by default, or a project directory passed as an argument (which must already exist).

Unlike the scaffolding commands, these update a config file that's already there rather than refusing: the SCUA configurations are inserted into your existing list, leaving your other debug configurations — and any comments or formatting — untouched. Running the command again when a SCUA configuration is already present does nothing.

scua fmt [--check] <file>

Reformat a file in place. The formatter is deliberately conservative: it fixes each line's leading indentation (two spaces per block level) and trims trailing whitespace. It does not reorder tokens, change spacing inside a line, or touch the contents of strings and comments. A reformatted file always means exactly what it did before.

Given this file with a missing indent and some trailing spaces:

fn greet(who)
return `hi {who}`   
end
print(greet("Sam"))

Running scua fmt rewrites it to:

fn greet(who)
  return `hi {who}`
end
print(greet("Sam"))

Add --check to verify without writing. It changes nothing on disk and exits 0 if the file is already formatted, or non-zero with a message if it isn't, which is what you want in CI:

$ scua fmt --check greet.scua
scua: greet.scua: not formatted (run `scua fmt greet.scua`)

Once the file is formatted, the same command exits 0 and prints nothing.

What counts as a level. Every block opener (fn, then, do, match, try, and the statement-position partition/migrate/on/ask) adds one level until its end, and every open bracket ( [ { adds one until it closes. Brackets count individually, so a table or list that opens on the same line as the call it is passed to sits two levels in, and its closer sits one level in, because the call's ( is still open when the } closes:

let m = machine.new({
    initial = "cold",
    transitions = [
      { event = "warm", from = "cold", to = "warm" },
    ],
  })

The one exception is a bracket that immediately wraps a block opener, each(items, fn(x) … end): the fn provides the level and the ( adds none, so the body sits one level in and end) returns to the call's column. A name that happens to be spelled like a keyword (on = 2 in a table, end: int in a record, spec.then) is a name and opens nothing.

If the formatter reaches the end of a file with a block or bracket still open, it refuses with a message and writes nothing, and --check reports that as a failure. That is deliberate: an opener with no closer would have pushed every following line one level in, and no test can catch a wrong indentation because indentation is not meaningful to the language.

Because the formatter only touches indentation and trailing whitespace, spacing inside a line is left exactly as you wrote it. a+b stays a+b, and let x = 1 keeps its inner spaces. So scua fmt and --check are no-ops on a file whose indentation is already correct, even if its operator spacing isn't to your taste. Normalizing intra-line spacing is not something the formatter does today.

scua fmt --fix file.scua also renames each call of the built-in as(…) (and std.as(…)) to parse(…), its new name, and says how many it changed. It formats the file as scua fmt does too. A file that declares its own as (fn as, a let as, a parameter of that name) is left alone, and told why.

scua schema <file> --type Name

Emit a JSON Schema draft 2020-12 document for a record or enum declared in <file>. Used by agent harnesses and MCP as inputSchema / outputSchema. Only the decidable subset of where refinements maps; SCUA contracts stay authoritative. See Write tools for AI agents.

$ scua schema examples/agent_tool.scua --type Args

Like a run, it checks the project's scua-version first (exit 3 when this scua is too old), and a syntax error in the file is reported with its line, in the same words a run uses (exit 3).

scua blob-info <file>

Prints what a saved actor (the bytes actors.snapshot returns) or a saved session holds, without running anything: its formats beside the ones this build reads, its generation, its identity as the locator logs use, whether a turn was parked, whether it was signed with a deployment key, the parts it is made of, how many functions it refers to, and the record types it holds, by name and field list. A revive looks each of those up by name in the program doing the reviving, so this is the question to ask before a deploy: which saves will a change to Player refuse?

$ scua blob-info session.bin
actor blob, actor format 8 (this build reads it)
  generation      3
  identity        actor:3f1c09a2b7d45e60
  parked turn     none
  integrity       unkeyed: a checksum, which catches damage, not changes
  section 1       partition, 2926 bytes
  section 2       grants, 21 bytes
partition image, format 14 (this build reads it)
  payload         2840 bytes
  fingerprint     91d0f47800f98b20
  functions       1 (1 named, 0 anonymous)
  record types    1 held, 0 named only by the method table
    held partition Holder {held;}

A save from an older version of SCUA says so and stops after its header: this build can't load it. Bytes that are not a SCUA blob at all exit with status 1.

Signed saves. A blob written under --blob-keys, or a session an embedding host saved with its deployment keys (scua_set_blob_keys), names its key by an id, and blob-info checks it against the keys you give it with --keys:

$ scua blob-info --keys deploy.keys session.bin
  …
  integrity       keyed (84622b2ba9758d4e), verifies

The line says verifies, DOES NOT VERIFY (the bytes changed after it was written), or that the key is not among the ones given. Give a directory instead of a file to check every save in it at once, each under its file name.

scua pack [--tier=compact|full|both] [--repo=DIR] [--out=PATH|-]

Generate a teach-a-model markdown pack (grammar, I/O model, stdlib cheatsheet, few-shots from examples/, reserved words, pitfalls). Token estimates print on stderr. Default --tier=both writes scua-pack-compact.md and scua-pack-full.md. Prefer compact in a system prompt. See Write tools for AI agents.

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

scua res build | list | check

Make and inspect the packs a program reads as res: paths. See Ship a program's data with it.

scua res build DIR -o FILE [--deflate=GLOB,…] [--exclude=GLOB,…] [--nfc]
scua res list FILE|APP.scuapp
scua res check FILE|APP.scuapp
  • build packs DIR into FILE, writing the same bytes for the same input. Files are stored uncompressed unless --deflate matches them. --exclude leaves files out. --nfc writes every name in NFC, the composed form a program types. SOURCE_DATE_EPOCH sets the timestamp it records. It refuses a symlink and a name that would not unpack on every OS, and warns about a name that is not in NFC.
  • list prints every entry, with its method, size and CRC, and the pack's digests.
  • check runs every check a mount runs, reads every entry against its CRC, and exits 1 naming each damaged entry.

list and check take a .scuapp too, and check its files against the SHA-256 the bundle records.

#scua user path ID

Print where a program's per-user folder is, one line per id, whether or not it exists yet. It creates nothing, and says on stderr when the folder does not exist or was not made by scua.

$ scua user path com.unabated.tinykeep dev.acme.lint
/Users/ed/Library/Application Support/com.unabated.tinykeep
/Users/ed/Library/Application Support/dev.acme.lint

A bad id stops it with status 2 and E-USER-ID, and so does a platform where it cannot find the folder (E-USER-NOBASE).

scua user adopt ID [--yes]

Use an existing folder that scua did not make as ID's per-user folder: one copied from another machine by hand, or one Steam Cloud downloaded before the game first ran. It prints the folder and what is in it, with sizes, and changes nothing, exit 1. With --yes it writes the folder's .scua-user marker, exit 0. It never moves or deletes anything.

$ scua user adopt com.unabated.tinykeep
/Users/ed/Library/Application Support/com.unabated.tinykeep
  saves/
scua user adopt: nothing was changed — to use this folder for com.unabated.tinykeep, run `scua user adopt com.unabated.tinykeep --yes`
$ scua user adopt com.unabated.tinykeep --yes

A folder whose marker holds another id (E-USER-MISMATCH), whose name differs from the id only in case, or whose marker is not an id (E-USER-FOREIGN), is refused with status 2, and so is one that fails the ownership rule below (E-USER-OWNER).

scua cluster dump <file.scdb> / scua cluster keys <file.scdb>

Inspects a durable shared-config snapshot — the file a table written with cluster.open(name, { durable = "…" }) leaves on disk. It reads the file offline; it does not join the cluster, so it's safe to run against a snapshot on any machine without perturbing a live mesh.

dump prints every key with its value, in sorted order, with deleted keys marked and the writing node and clock shown:

$ scua cluster dump game_config.scdb
economy/gems = 5  [node 1 @ 1784071308990]
economy/gold = 100  [node 1 @ 1784071308990]
flags/new_shop = true  [node 1 @ 1784071308990]
graphics/res = (deleted)  [node 1 @ 1784071308990]
# 3 live, 1 tombstone(s)

keys prints just the live key names, one per line — handy for piping into another tool. A missing or corrupt file is a clear error and a non-zero exit, not a crash.

#scua test [dir|file]

Find and run tests. It discovers every *_test.scua file under dir (the current directory if you don't give one), runs each, and reports pass/fail. A test fails when it faults or when it returns an Error, including through ?. It exits non-zero if anything failed, so it drops straight into CI.

Name a file instead and it runs just that one — what you want constantly while working on a single test:

$ scua test tests/parser_test.scua
PASS  tests/parser_test.scua  test_parses_a_call

1 passed, 0 failed (1 file)

The *_test.scua suffix is how files are discovered in a directory; a file you name yourself runs whatever it is called. Discovery follows symlinks, so a test directory assembled out of links works the same as one of real files.

A test file can work two ways. If it declares fn test_*() functions, each one runs on its own: a fault in one (a failed assert, say) fails just that test, and the others still run. If it has no test_* functions, the whole file is a single test that passes if it runs without an uncaught fault.

-- math_test.scua
fn test_addition()
  assert_eq(1 + 1, 2)
end
fn test_division()
  assert_eq(7 // 2, 3)
end
$ scua test
PASS  ./math_test.scua  test_addition
PASS  ./math_test.scua  test_division

2 passed, 0 failed (1 file)

Write assertions with assert(cond, msg?) or the comparison helpers assert_eq(got, want), assert_ne(a, b), and assert_near(got, want, eps) (float-tolerant). A failing comparison reports both values, in full: a test run shows what an Ok holds, which an ordinary run's fault messages leave out (see --debug-values). See Test your code for the full walkthrough.

A compile warning is printed once per scua test run, however many test files import the module it's in.

scua test takes --mod-path DIR, --deny-warnings, --dep-warnings, --no-warnings and --warnings, which work as they do for a run, and it follows warnings in the project's scua.toml as a run does without --profile. With --deny-warnings, a file whose compile warnings would stop a run fails, every diagnostic listed in its result, and its tests don't run. It also accepts --debug-values and ignores it, since a test run shows full values already.

Tests read res: as the program does: from the project's res/ folder, with no flag. scua test also takes --mount=PATH and --res=PATH, relative to the project root rather than to each test file. A test run grants no host files, so a test that reads one fails. See Testing code that reads the program's data.

#debug

$ scua debug file.scua

Runs the script under a terminal stepper instead of straight through, so you can step line by line and inspect state as it executes. This is an interactive session in your terminal, not a flag you combine with normal output. Fault messages under the debugger show full values, including what an Ok holds.

#Package verbs

$ scua add @acme/json
$ scua publish --registry /srv/acme-registry
$ scua vendor

These are handled by scua-pkg, a separate binary on its own release stream, which scua locates on PATH and executes — the same model as git's and cargo's external subcommands. The dispatched verbs are:

add remove update upgrade fetch vendor bundle verify why explain diff sbom trust doctor publish yank search audit registry

scua-pkg --help is the authoritative list and the place their flags are documented.

Two things this arrangement guarantees, both deliberate:

  • Running programs never needs it. scua file.scua, scua test, scua fmt, and scua debug all work with scua-pkg absent. Nothing on the script path touches the package manager.
  • No network code is in this binary. All fetching, TLS, registry, transparency-log, and trust-policy code lives in scua-pkg, which is what makes a network-free build flavour of scua possible at all.

If the tool is not installed:

$ scua add @acme/json
scua: E-PKG-TOOL-MISSING: `scua add` needs scua-pkg, and it is not on PATH.
…

The toolchain passes its own version to scua-pkg on every dispatch (--toolchain 0.13.0). scua-pkg supports the current and previous two toolchain minors, and refuses outside that window with E-PKG-TOOL-SKEW rather than emitting a lock or vendored tree this toolchain might half-read.

#--fast

Fast-forward the virtual clock. Timer-based waits resolve instantly instead of taking real time, while producing the same output and the same reported timestamps. Use it for demos, simulations, and tests where you don't want to sit through the waits.

A scheduling script that describes five seconds of game time runs in milliseconds:

$ scua --fast scheduling.scua
world: spawned 2 tasks
[patrol] move to A  (t+0ms)
[regen]  +10 hp     (t+1000ms)
[regen]  +10 hp     (t+2000ms)
[patrol] move to B  (t+3000ms)
[patrol] back to A  (t+5000ms)

The t+...ms values are the in-program clock, so they still read 1000, 2000, and so on even though no real time passed.

--dump-bytecode[=verbose]

Compile the script, print the bytecode the compiler produced, and exit without running it. It's a diagnostic window into how your code lowers, handy when you're curious what a construct compiles to or filing a bug. --dump-bytecode=verbose prints more detail per instruction.

$ scua --dump-bytecode hello.scua

The format is meant for a human reading it, not as a stable interface, so it can change between releases.

It is also the way to check that a whole tree still compiles, which is worth knowing when you upgrade. Because it stops before running, you can point it at every file you have and let the compiler answer, rather than searching for the constructs you think changed:

$ for f in $(find . -name '*.scua'); do scua --allow-all --dump-bytecode "$f" >/dev/null || echo "$f"; done

Two things make that better than a search. The compiler knows the exact set of escapes, keywords and forms it accepts and you are working from memory, so a grep encodes what you believe the language does. And the first error in a file hides the rest, so a file that failed for one reason may still hold another: after fixing one, compile again rather than assuming the file is clean.

--allow-all is there because resolving an import of a capability module checks the grant, so without it you get refusals for files that are fine. Files that reference a host-provided global, or a declarations file, will still be reported, and are the expected exceptions rather than problems.

#--log=<level>

Set the minimum log level. SCUA's logging has five levels, lowest to highest: trace, debug, info, warn, severe. Anything below the level you pass is removed at compile time, so those statements and their arguments never run. The default prints everything.

Given a script that logs at several levels, the default shows them all:

$ scua logging.scua
[info] player Mara joined
[debug] hp is 30/100
[warn] hp low: 30
[severe] save upload failed, retrying from cache
...

With --log=info, the trace and debug lines are gone (and any work done only to build them is skipped):

$ scua --log=info logging.scua
[info] player Mara joined
[warn] hp low: 30
[severe] save upload failed, retrying from cache
...

An unknown level is rejected:

$ scua --log=bogus file.scua
usage: --log=<trace|debug|info|warn|severe>

See Logging for the levels and how log statements are written.

#--log-time

Prefix each log line with an ISO-8601 timestamp. Useful when you want logs you can correlate with other systems.

$ scua --log-time logging.scua
2026-06-18T23:29:55.478Z [info] player Mara joined
2026-06-18T23:29:55.478Z [debug] hp is 30/100
...

#--log-stdout

Log lines go to standard error by default, because a log line is a diagnostic rather than the program's output. print and sys.write go to standard output; info, warn and severe do not. That means a caller can parse a script's stdout without a log line turning up in the middle of it, and without either side having to arrange anything:

$ scua tool.scua 2>/dev/null
{"ok":true,"total":"19.99 USD"}

$ scua tool.scua 1>/dev/null
[warn] two rows had no price

--log-stdout puts them back together, for when you want a single merged stream to read yourself and don't care which is which. (scua tool.scua 2>&1 also merges them, and keeps them in the order the script wrote them — that ordering holds either way.)

It combines with --log and --log-time; the level floor and the timestamp work exactly as before.

#--output=<text|json>

Declare what this run's standard output is.

text is the default and the ordinary case: stdout is whatever the script prints, and sys.emit's result — if the script calls it — is appended to the same stream.

json says stdout is the tool's result channel. sys.emit writes there and nothing else does: print, sys.write and log lines all go to standard error instead. So a caller gets exactly one JSON value on stdout, whatever the script prints along the way:

$ scua --output=json tool.scua --a=1 --b=2
{"sum":3}

$ scua --output=json tool.scua --a=1 --b=2 2>&1 1>/dev/null
computing...
[warn] a diagnostic

Nothing is discarded — the script's own output is still there, on the other stream, and a script doesn't have to be written differently to be run this way. Use it whenever something is parsing the result; leave it off when a person is reading the output.

It's a flag on the run rather than on the script, because the caller parsing stdout as JSON is already the one who knows it wants JSON — so saying so costs it nothing, and a source file never has to describe how the process that runs it will be read.

#--jit[=on|always|off]

Choose how the JIT runs. It is on by default: SCUA compiles the hot loops, the ones that run enough times to be worth it, to native code, which makes long-running numeric and table-heavy work much faster. The result is identical to the interpreter: the JIT changes speed, not behaviour. The one thing it can change is timing between tasks or partitions that are busy at the same time: compiled code may hand over to the others at slightly different points, so their output can interleave differently. That order is not something to rely on either way.

fn sum_to(n)
  let sum = 0
  for i in 1:n do
    sum = sum + i
  end
  return sum
end
print(sum_to(1000000))
$ scua --jit=on sum.scua
499999500000

The three values:

  • --jit=on is the default: compile hot loops as they warm up.
  • --jit=off runs everything on the interpreter.
  • --jit=always compiles eagerly instead of waiting for a loop to warm up. It's slower to start and mainly useful for testing that the JIT and the interpreter agree.

The JIT runs on ARM64 (Apple Silicon and ARM Linux) and x86-64 (macOS and Linux). On any other platform the flag is accepted and ignored, and the script runs on the interpreter, so a script and its output are the same everywhere, with or without the flag. See Make scripts run faster with the JIT.

#--jit-stats

Print a one-line summary of what the JIT did to stderr when the run ends: how many functions it compiled, how many times native code was entered, and how many times it fell back to the interpreter (a "deopt"). Use it to confirm the JIT is engaging on your hot path. Passing --jit-stats also turns the JIT on, so you can use it on its own.

$ scua --jit-stats game.scua
[jit] mode=on compiled=2 native-entries=5 deopts=3 ...

The exact counters are for diagnostics and vary from run to run and release to release. A compiled= above zero with a healthy native-entries= means your hot loop is running as native code; all zeros means nothing ran long enough to compile. In a program with partitions, shared-compiled= counts the functions compiled once for all of them: each partition runs that one copy rather than compiling its own.

#-D name[=value]

Set a build flag that comptime if reads from the build config. comptime if chooses a branch at compile time, and only the chosen branch is compiled into the program. A bare -D name sets the flag to true; -D name=value gives it a value. You can pass -D more than once.

Given a script that gates output on build.debug and build.tier, with no flags both gates take their default branch:

$ scua conditional.scua
free edition
game starting

Setting the flags selects the other branches:

$ scua -D debug -D tier=pro conditional.scua
[info] debug diagnostics enabled
pro edition: all features unlocked
game starting

See Conditional compilation for how comptime if and build work.

#--profile <name>

Select a named build profile from a scua.toml project manifest. A profile is a bundle of build flags, so you don't have to type the same -D flags every time. The manifest names a default profile that's used when you don't pass --profile.

For example, a scua.toml like this:

default = "debug"

[build.debug]
debug = true
tier = "free"

[build.release]
debug = false
tier = "pro"

A plain run uses the default (here debug) profile:

$ scua app.scua
[info] debug diagnostics on
free edition

Asking for the release profile applies its flags instead:

$ scua --profile release app.scua
pro edition

The flags set by a profile are the same build flags -D sets, so the same comptime if gates respond to both.

A profile can also carry run settings, which your program can't see, in a [run.<name>] table beside [build.<name>]; a profile may have either table or both. See scua.toml.

#--mod-path DIR

Add a directory to search when resolving an import. By default import name looks only next to the entry file (<entry-dir>/name.scua); each --mod-path DIR adds another directory, tried in order, after the entry directory. Repeat the flag for several directories. This is how you keep shared modules in a separate folder, or split a project across directories.

$ scua --mod-path libs app.scua
$ scua --mod-path libs --mod-path ../shared app.scua

With app.scua containing import greet, the runner tries ./greet.scua, then libs/greet.scua, then ../shared/greet.scua. If more than one of them exists the run is refused with both named, never a silent pick; --mod-override greet=DIR chooses one deliberately. A module's own imports look beside the module first (libs/greet.scua importing util finds libs/util.scua), then walk the same list. The built-in standard-library modules (math, list, …) always resolve first, and a file of yours carrying one of their names is refused when that name is imported rather than silently unused. If no directory has the module, the error names how many it tried:

$ scua app.scua
scua: app.scua:1: cannot resolve module 'greet' (tried 1 resolver)

(The directories form an ordered resolver chain. Today the chain is set on the command line; a future release adds project-manifest and in-script control.)

#--allow-file=PATH

Grant capabilities from a file instead of a row of flags. This:

$ scua --allow-fs=./data --allow-net=api.internal,cdn.example --allow-env=PORT,HOME \
       --allow-serve=127.0.0.1:8080 app.scua

becomes this:

# permissions.toml
[capabilities]
fs = "./data"
net = ["api.internal", "cdn.example"]
env = ["PORT", "HOME"]
serve = "127.0.0.1:8080"
$ scua --allow-file=permissions.toml app.scua

The keys are the flag names, so there is nothing new to learn: true means the bare flag (fs = true is --allow-fs, rooted at the working directory), a string or list is the constrained form, and false grants nothing — useful when you want a policy to say "not this" out loud rather than by leaving it out.

Two rules worth knowing. An empty list grants nothing: net = [] means no hosts, not any host. And an unknown key is an error, not a warning — a typo like nett would otherwise silently grant nothing and show up much later as undefined name inside your script, with the file still looking right.

The [capabilities] header is optional. A file that is nothing but the table works too, so a dedicated permissions file needs no ceremony.

false and [] are refusals, and they stick. That is what lets you say "everything except one thing" in a single command:

$ scua --allow-all --allow-file=no-net.toml app.scua     # no-net.toml is just: net = false

Any key can be the one you refuse — net = false, tty = false, fs = false — and the refusal holds against --allow-all on the same command line.

mount lists read-only res: layers, as --mount does, with the same paths, globs and pins:

mount = ["base.pak", "patches/*.pak", "extra.pak#sha256=9f86d0…"]

It is not a capability, since a mount grants nothing on the host. Any --mount on the command line replaces the whole list, and --show-res names the entries it ignored. See Ship a program's data with it.

user = "ID" and user-dir = "PATH" grant the per-user folder, as --allow-user and --user-dir do. They are honoured only from a file outside every folder the run can write, and with one hard link unless no link can be written through (owned by you or root, with no write permission for anyone), because a program could otherwise rewrite the file to choose its own folder next time; anywhere else the run stops with E-USER-POLICY. user = true is refused, since there is no folder without an id.

A file scua cannot use (unreadable, not TOML, an unknown key, a value of the wrong kind) stops the run with status 2.

Only a file you name applies. A scua.toml beside your code grants nothing — capabilities come from what you type, so a script cannot arrange to be trusted more next time.

#--allow-all

Grant every capability, each in its broadest form — fs at the working directory, any host, any bind, any peer, every environment variable, the empty cluster secret, the terminal. It never grants the per-user folder: that needs an id, which only --allow-user names.

$ scua --allow-all app.scua

This is for a script you already trust — usually your own. It is the honest answer to "just run it", which otherwise tempts people into not sandboxing at all; it is not something to reach for with code you have not read.

A narrower --allow-* flag still wins, which makes "everything, except this one thing is restricted" a single readable command:

$ scua --allow-all --allow-net=api.internal app.scua    # everything, but net only reaches api.internal

#Which grant wins

Three places can grant a capability. They are resolved one capability at a time, most specific first:

  1. a --allow-* flag on this command line
  2. --allow-file=PATH
  3. --allow-all

A flag is the most specific thing you can say, so it is never overwritten and never widened. Everything else fills in the capabilities you did not name.

All three are things you type. A capability file only applies when you name it, so nothing a script can reach — including a scua.toml beside your code — grants anything on its own. The [exec] list in scua.toml is the same: it names commands, and only --allow-exec on the command line grants them.

#--allow-fs[=DIR]

Grant the script the fs capability — the ability to read, write, and delete files and directories — rooted at a directory. Without this flag a script has no filesystem access at all: import fs is a compile error, because the fs module isn't installed (this is the default-deny capability model). With it, fs is available and every path the script uses is resolved within the granted directory; absolute paths and .. are rejected, so the script can't reach outside the root. That root is also the whole blast radius of fs.remove/fs.remove_all, which cannot delete the root itself and unlink a symlink rather than following it — so grant the narrowest directory that works.

--allow-fs alone roots at the current working directory; --allow-fs=DIR roots at DIR.

The folder must exist: a missing one, or a file, is refused before the program runs (--allow-fs=gen: no such folder; create it first, exit 2). The same goes for the fs key of a policy file.

import fs
match fs.read("notes.txt")
  Ok(text) -> print(text)
  Error(why) -> print(why)
end
$ scua notes.scua
notes.scua:1: module `fs` needs the `fs` capability, which this run was not granted — grant it with `--allow-fs` on the CLI, or the host's capability API when embedding

$ scua --allow-fs notes.scua
(the contents of notes.txt)

See Read and write files for the full surface (fs.read/fs.read_text/fs.write/fs.rename/fs.mkdir/fs.remove/fs.remove_all/fs.list/fs.exists/fs.stat/fs.lstat/fs.find/fs.grep) and the rationale.

--allow-user=ID, --user-dir=PATH and --user-adopt

Grant the program a per-user folder for its saves and settings. The program writes user: paths, such as user:saves/slot1.json, and every fs function takes them. It is a second writable folder beside --allow-fs, so a tool can hold both: the user's project, and its own settings.

$ scua --allow-user=com.unabated.tinykeep game.scua
$ scua --allow-fs=. --allow-user=dev.acme.lint lint.scua
$ scua --user-dir=save game.scua      # while developing: a folder beside the game
  • --allow-user=ID grants the folder the platform keeps for ID: %APPDATA%\ID (Roaming) on Windows, ~/Library/Application Support/ID on macOS, and $XDG_DATA_HOME/ID (by default ~/.local/share/ID) on Linux. scua user path ID prints it. An id is three or more lowercase parts separated by ., each a letter followed by letters and digits, 7 to 128 bytes: a reverse-DNS name you own, such as dev.example.mytool. A bad one stops the run with E-USER-ID and the fix.
  • --user-dir=PATH grants the folder at PATH itself; nothing is appended. A relative PATH resolves against the program file's folder, as --mount does, so a launcher works from any working directory. On its own it is a grant; with --allow-user it is where that id's folder lives.
  • --user-adopt is for a shipped launcher: it lets the run take over a folder for its id that exists but was not made by scua, such as one Steam Cloud downloaded on a second machine.

A project may declare its id in scua.toml, as a check, not a grant:

[app]
id = "com.unabated.tinykeep"

A launch that names a different id then stops with E-USER-MISMATCH, since a different id is a different, empty save folder.

fs.mounts().user is nil without the folder, and otherwise { id, path, created }. path is the folder's absolute path when the run also holds --allow-fs, and "" otherwise. fs.mounts().state names the folders the run can reach, joined by + in the order host, res, user: "host+res", "res", "user", "res+user", "host+res+user", or "neither".

A program that uses user: paths still runs without the folder: a one-line note on stderr says so before it starts, and a user: call it reaches stops the run with status 4, naming the flag to add. So a program that saves only when it can tests first:

import fs
if fs.mounts().user != nil then
  fs.write("user:settings.toml", "volume = 3")?
end

When the program's scua.toml declares no [app] id, or there is no scua.toml, nothing checks the id --allow-user=ID names, so a mistyped one would get a new, empty folder. The run goes ahead and says so once, on stderr, suggesting the [app] id line to add (--no-warnings hides it).

The run stops with status 2 and an E-USER-… code first on the line, before any of the program runs, when the launch cannot work as written: --allow-fs=user:ID (E-USER-FLAG: use --allow-user=ID), a bare --allow-user (E-USER-ID), no HOME to find the platform folder from (E-USER-NOBASE: pass --user-dir), or a folder that is, or contains, the --allow-fs folder (E-USER-OVERLAP, because one file would have two names). A folder inside a wider --allow-fs (--allow-fs=$HOME) is allowed, and --show-res says so. Nothing with authority is read from inside the folder: the program file and its scua.toml, --allow-file, --env-file, --mod-path, --blob-keys and --mount are refused there, since the program could rewrite them for the next run. The program file, its scua.toml, --allow-file and --env-file are also refused with a second hard link, which could be inside the folder, unless no link can be written through: the file is owned by you or root, no one has write permission, and the run is not root. A Nix store's files pass; copy a writable file out.

Holding the folder. Before the program runs:

  • A missing folder is created, mode 0700, with any missing folders above it for --allow-user, and the run says so: scua: created the per-user folder for com.unabated.tinykeep: …. --user-dir creates only its last folder, so a missing parent (an unplugged drive) stops the run with E-USER-NOPARENT.
  • The marker. A folder scua creates gets a .scua-user file holding the id (hidden on Windows). An existing platform folder without one is not scua's: the run stops with E-USER-FOREIGN, naming scua user adopt and --user-adopt, and with --user-adopt it writes the marker and says so (scua: adopted the existing folder for …). A folder whose name differs from the id only in case is refused, since adopting would not help. A folder named with --user-dir gets a missing marker written. A marker holding another id is E-USER-MISMATCH. No fs call, through any folder the run holds, reads, writes, creates or removes .scua-user, fs.list, fs.find and fs.grep skip it, and fs.remove_all refuses a folder that holds it.
  • One run per folder. A second run of the same folder waits up to about 5 seconds, printing scua: waiting for another run using user: com.unabated.tinykeep… after one, then stops with E-USER-LOCKED: user: com.unabated.tinykeep is in use by another running scua …, exit status 9. The wait covers an overlap, not a queue: a run holds the folder until it exits, so many short runs in parallel (xargs -P 8) over one id stop with E-USER-LOCKED; run them one after another. Where the file system has no locks (some network shares), the run goes ahead with a warning.
  • --user-shared lets several runs use the folder at once, each launched with it: two windows of one tool server, or a command-line helper beside the running app. A shared run and one without the flag still exclude each other, so a game keeps its folder to itself. When the runs holding the folder share it and this launch has no --user-shared, the E-USER-LOCKED message says that this launch is the one missing the flag. Runs that share a folder coordinate their writes with fs.lock.
  • Ownership. The folder must be owned by you, not writable by other accounts, and group-writable only when the group is your own primary group; every folder above it must be owned by you or root and not writable by others (unless sticky, as /tmp is). Otherwise the run stops with E-USER-OWNER, naming the folder, its owner and its mode, and the chmod that fixes it. On a file system that stores no modes (FAT, exFAT, a Mac drive that ignores ownership) only the owner is checked, and --show-res says so. Windows relies on the profile's permissions.
  • A link at the folder's place is followed, so the saves can live on another disk; a link to a folder that does not exist is E-USER-LINK, and is never replaced.

--no-warnings silences the creation and adoption notices and the no-lock warning.

#--mount=PATH, --res=PATH and --show-res

A program reads its own data from res: paths: the project's res/ folder, and packs above it. These flags change what res: holds. They grant nothing on the host, so they need no --allow-fs.

  • --mount=PATH adds a read-only layer above the others: a pack, a directory, or DIR/*.pak for every pack there in natural order. It is repeatable, and a later one wins. PACK#sha256=HEX refuses to run unless the pack's SHA-256 matches.
  • --res=PATH uses a pack or a directory in place of the project's res/ folder. --res=none turns that bottom layer off.
  • --show-res prints the layers to stderr, top first, and then runs the program. With a per-user folder it prints one more line, its id and path, and another when the folder sits inside --allow-fs.

A relative path resolves against the program file's folder, so a launcher works from any working directory. A pack that fails a check stops the run with exit status 2 and an E-PACK-… code.

A save records the layers it ran with, and two flags decide what a revive does when they differ. See Saves and the packs they ran with.

  • --res-missing=allow revives a save although a layer it had is not mounted now.
  • --strict-res refuses to revive a save whose layers changed since it was made.

--allow-file's mount key lists mounts in a policy file, and scua test takes --mount and --res too. See Ship a program's data with it.

#--allow-net[=hosts]

Grant the script the net capability — the ability to make HTTP requests via the http module. Without it, import http is a compile error (default-deny). With it, the http module is available and the CLI backs it with a built-in client (Zig's std.http and its pure-Zig TLS, so HTTPS works with no system dependency).

A bare --allow-net allows any host. --allow-net=host1,host2 restricts the script to a comma-separated allowlist — a request to any other host returns an Error before a packet is sent.

import http
match http.get("https://example.com")
  Ok(resp) -> print(resp.status)
  Error(why) -> print(why)
end
$ scua fetch.scua
fetch.scua:1: module `http` needs the `net` capability, which this run was not granted — grant it with `--allow-net` on the CLI (a program that only serves needs `--allow-serve` alone), or the host's capability API when embedding

$ scua --allow-net=example.com fetch.scua
200

See Fetch data over HTTP for http.get/http.post, the response shape, and the allowlist.

--allow-serve[=ADDR:PORT]

Grant the script the serve capability — the ability to listen and answer requests, via http.serve and the raw net.listen/net.accept. This is a different authority from --allow-net: making outbound requests never lets a script open a port, so listening is its own grant. Without it, http.serve and net.listen don't exist to call (default-deny), and --allow-net, however broad, never confers it.

The grant names the address the script may bind: --allow-serve=127.0.0.1:8080 allows binding exactly that address and port, and nothing else. A bare --allow-serve allows any bind — reserve it for a host you trust.

$ scua --allow-serve=127.0.0.1:8080 serve.scua

See Serve HTTP requests for the handler shape, the request/response records, and raw TCP.

#--serve-debug

Put the fault text into an http.serve 500 body. By default, when a handler faults or returns an Error, the client gets a 500 whose JSON body holds only a reference ({"error":"internal server error","ref":"r-…"}), which the server's log line for that request also names, and the located message is withheld: production bodies don't leak an internal detail to whoever is calling. --serve-debug puts that message in the response body so you can see it while developing locally. Leave it off in anything real.

Either way, the message goes to stderr as one line naming the request, such as scua: app.scua:12: http.serve GET /orders/7 answered 500: …, rate-limited to 20 lines at once and then one a second. See Faults a running program survives.

$ scua --allow-serve=127.0.0.1:8080 --serve-debug serve.scua

#--serve-check

Run the program up to its first http.serve call, apply every check http.serve makes before it starts serving, and exit instead of opening the port. It is for CI: a test suite that drives a handler through a stub never reaches the call, so it cannot see the refusals that happen there, such as a handler that reaches a let, a function in context that does, or module state written before serving.

  • Every check passes: one line on stderr naming the handler and the bind, and status 0. Nothing is bound, and the lines after the call do not run.
  • A check refuses: the same message and the same status as without the flag (5 for a fault).
  • The program ends, or calls sys.exit(0), without starting a server: status 8 and a line saying so, so the step cannot pass by accident. That includes a program that carries on past an Error that http.serve handed back instead of starting (under --io=blocking, for example).
$ scua --allow-serve=127.0.0.1:8080 --serve-check app.scua
scua: --serve-check: http.serve would serve `handle` on 127.0.0.1:8080; every startup check passed, and nothing was bound

The startup code before the call runs for real, so pass the same grants and flags as the real run: the checks read them. Because the port is never opened, it cannot tell you whether the port is free where the server will run. See Check a server in CI without starting it.

#--blob-keys=FILE

Sign every actor snapshot this run writes with a key of your deployment, and revive only snapshots that verify against your keys. A snapshot carries everything about its actor, including what it is allowed to do, so on a server where saves pass through storage or a network you don't fully control, sign them: a blob changed after it was written, or written by anyone without your key, is refused with kind = "integrity".

FILE holds the keys, one per line, each 64 hex digits (32 random bytes). The one to sign with is followed by the word sign; the others only verify. # starts a comment:

# deploy.keys — keep this file out of every directory a script may read
4f1d…e2 sign      # current
9a07…c3           # previous: still verifies the saves it signed

To rotate, add a new key marked sign and drop the mark from the old one: saves signed under the old key still revive. When actors.stats().loads_by_key stops counting revives under the old key's id, remove it.

The keys never reach your script: no function reads them, and the run refuses to start if FILE is inside the directory --allow-fs grants or the per-user folder, has a second hard link while either is granted (which could be inside it), is inside a folder mounted as a res: layer, or is itself a mounted pack, or if --allow-exec-any is given (a script could run a command that reads it). They are never taken from the command line itself or the environment.

A keyed run refuses a snapshot saved without a key. While you move a deployment over, pass --accept-unkeyed-blobs to revive those too.

#--allow-fork

actors.revive(blob, { fork = true }) starts a second live copy of a saved partition, with its state, its grants and every reference it holds, under an identity of its own. That is more than reviving, which brings back the one copy, so it needs this flag: without it a fork returns Error({ kind = "not_granted" }), and a plain revive works as before. The partitions your program creates inherit it from the code that created them; a sandbox never has it. --allow-all does not include it. A host embedding SCUA grants it with scua_grant_fork.

#--strict-constants

A revived actor that stored a value it took from a top-level constant gets the current program's value (see what a revived partition sees). When the constant is gone, or the place the value came from has changed, the actor keeps the value it stored, and the load report counts it. --strict-constants refuses such a revive instead (kind = "constant_gone" or "shape_changed"), for a deployment where a removed constant should stop things loudly.

#--debug-values

Show full values in fault messages. A fault message names the value that caused it, but never shows what an Ok holds, because that could be a decrypted secret and stderr often goes to a logging service: it says got an Ok instead. When you need the value to understand a fault and can't run the debugger, pass --debug-values:

$ scua --debug-values secret.scua
scua: --debug-values is on: fault messages and logs will show the values they would otherwise hide (decrypted data and secrets included). Never use it in production.
scua: secret.scua:4: match: no arm matched (got Ok(hunter2))

It covers every text that hides an Ok's contents: a match with no matching arm, assert_eq and assert_ne, a condition given a Result, a Result refused where text is built (a template, .., str.join and the like), the value of a fault from a typed parameter, an Ok inside sys.fail's reason, and the stderr line for a partition handler whose turn ended with an Error. With --serve-debug too, an http.serve 500 body shows the same text.

The first line is always printed, before anything runs, so a log that holds values says why. The flag is read from the command line and nowhere else: scua.toml, a build profile, an environment variable and an application bundle can't turn it on, so a deployed configuration can't reveal values by accident. scua test and scua debug show full values without it. A host embedding SCUA never shows them.

#--check=deep

Every type annotation checks all the way down, as parse does, without converting anything: nested records, collection elements and map values, and their where rules. Without it an annotation checks one level. It only adds faults, so a program that passes with it passes without it; it costs a walk of each value, so use it for tests and CI rather than for shipping. SCUA_CHECK=deep in the environment does the same, and scua test --check=deep runs tests this way. See Checking typed data all the way down.

#--deny-warnings

Make a compile warning stop the run. Every diagnostic is printed as usual, and if any warning is in your own files, scua exits with status 3 before anything runs:

$ scua --deny-warnings report.scua
scua: report.scua:6: warning: `tonumber` is given an array ({ int }), which is never a number, so this always gives nil — pass it a string or a number
scua: nothing ran: --deny-warnings makes a warning fail the run

It is meant for CI and agent harnesses, which control the command line rather than the source. It applies only to warnings the checker gives before the run: a warning scua test or the debugger gives while a program runs never stops it. A warning in a package you depend on doesn't count unless you also pass --dep-warnings.

A file can ask for the same thing itself: when its first line is --!strict, a compile warning in that file stops the run, whatever the command line says.

#--dep-warnings

Show the compile warnings of the packages you depend on. A module that comes from your project's lock (deps/), or from a package inside an application bundle, is a dependency: you can't fix its warnings except by upgrading it, so they are quiet by default, and neither --deny-warnings nor the package's own --!strict fails your run on them. Files beside your script and on --mod-path are yours.

--dep-warnings prints them. With --deny-warnings as well, they count too, so a deploy gate can refuse a dependency that warns:

$ scua --dep-warnings --deny-warnings main.scua
scua: mathx.scua:2: warning: `tonumber` is given an array ({ any }), which is never a number, so this always gives nil — pass it a string or a number
scua: nothing ran: --deny-warnings makes a warning fail the run

A package's own CI runs its tests with the package as the root, where its files are its own and its warnings show.

#--no-warnings

Print no compile warnings for this run. Errors still print and still stop the run with status 3; only the warnings go quiet. It is for a tool or an agent that reads every line of stderr, where a warning about code it isn't changing is noise:

$ scua --no-warnings report.scua

It also hides the note a run prints before it starts when the program uses user: paths and has no per-user folder.

Two things still print:

  • A warning in a file whose first line is --!strict. That file made its warnings errors, so the run stops on it, and the line says why.
  • A notice that a package you depend on was written before a breaking change and needs updating. It is one line per package, and it is never hidden, because it is often the only sign that a package will fail when it runs.

It also quiets the warnings scua test and scua debug give while a program runs. Your editor keeps showing every warning, so you still see them while you write the code.

--no-warnings asks for the opposite of --deny-warnings and --dep-warnings, so passing it with either one is a usage error (status 2) rather than a guess at which you meant:

$ scua --no-warnings --deny-warnings report.scua
scua: --no-warnings and --deny-warnings ask for opposite things (one hides compile warnings, the other makes them stop the run); pass one or the other

A project can turn warnings off for everyone who runs it, with warnings = false under [run] in its scua.toml, or under [run.<profile>] for one profile (see scua.toml). The command line wins over the file in both directions, without an error: --warnings shows the warnings the file turns off, and --deny-warnings or --dep-warnings show them too, so a quiet project's CI can still run scua --deny-warnings. A script with no scua.toml warns as usual.

#--warnings

Print compile warnings even where the project's scua.toml turns them off (warnings = false under [run]). Without such a file it changes nothing, since warnings print by default. It can't be combined with --no-warnings.

$ scua --warnings report.scua

#--allow-exec and --allow-exec-any

--allow-exec lets the program run the commands listed under [exec] in the project's scua.toml, through exec.run and exec.spawn, and nothing else:

$ scua --allow-exec build.scua

The list is read from the project's own scua.toml; for a script inside a locked package's folder, that is the project whose lock vendored the package. The flag takes no value (--allow-exec=git stops with status 2, naming the [exec] table), and a launch whose scua.toml has no list, an empty one or a malformed template stops with status 2 before any of the program runs. An --allow-file exec = [ … ] list is the same grant from a file outside the project; with --allow-exec, the file's is not read.

A list of commands is honoured only from a file the program cannot rewrite. If it could, a run could add a command and the next run would execute it. So the launch stops with status 2 and E-EXEC-POLICY when the file holding the list is inside the --allow-fs folder (compared by real path, so --allow-fs=.., a home folder or a symlink to the project all count), when that folder holds the program's own folder (where a scua.toml the program wrote would be found first), when it is inside the per-user folder, or when it has a second hard link:

E-EXEC-POLICY: --allow-exec: the commands under [exec] in /home/me/tool/scua.toml are refused because /home/me/tool/scua.toml is inside --allow-fs=. — the program could add a command to the list and the next run would execute it. Grant a folder that holds neither the program nor its scua.toml, such as --allow-fs=./data

Grant the program a folder of its own for the files it writes, such as --allow-fs=./data. A packaged app's list is inside the app file, which no grant reaches, so it is never refused this way.

--allow-exec-any lets the program run any command: exec.run_any, exec.spawn_any, and exec.run and exec.spawn too, which still check a list when one is declared. A command runs as you, so this is delegation rather than containment, and --allow-all never grants it. See Run commands.

A program refused for a missing exec grant is told which flag it needs: --allow-exec when it calls only exec.run or exec.spawn, --allow-exec-any when it calls an _any verb.

#--allow-env[=NAMES]

Grant the script the env capability — the ability to read environment variables via the env module. Without it, import env is a compile error (default-deny). With it, the env module is available and env.get(name) reads from a snapshot of the environment taken when the run started.

A per-name allowlist is the safe default: --allow-env=NAME1,NAME2 exposes only those variables, and env.get returns nil for anything else, so a script can't read a secret you didn't name. A bare --allow-env exposes every variable.

import env
let home = env.get("HOME") ?? "(not set or not allowed)"
print(home)
$ scua whoami.scua
whoami.scua:1: module `env` needs the `env` capability, which this run was not granted — grant it with `--allow-env` on the CLI, or the host's capability API when embedding

$ scua --allow-env=HOME whoami.scua
/Users/sam

See Read environment variables for env.get, the snapshot semantics, and the allowlist.

#--env-file=PATH

Load a .env file and grant env for exactly the names in it:

$ scua --env-file=.env app.scua

Naming the file is the grant — no separate --allow-env, and no writing the file's key list twice. With --env-file alone the script sees the file's names and nothing else: the real process environment stays hidden, so a stray secret in your shell can't reach it. Add --allow-env=NAME,... for specific real variables too.

Precedence is one rule — nothing overwrites what is already set — applied in this order:

  1. the granted slice of the real process environment
  2. the first --env-file
  3. each later --env-file

So a real variable beats the file (which is what makes .env a defaults file), and an earlier file beats a later one:

$ DB_HOST=prod.internal scua --env-file=.env app.scua      # the real DB_HOST wins
$ scua --env-file=.env.local --env-file=.env app.scua      # .env.local wins; .env fills the gaps

The format is the usual one: KEY=value, # comments, an optional export prefix, 'literal' and "basic" quoting (the latter processes \n, \r, \t, \\, \"), either able to span lines. KEY= is the empty string — set, not absent — and a repeated key keeps its last value (while across several --env-files, the first file wins).

Two things worth knowing: a Windows path needs single quotes ("C:\temp" contains \t, so it reads as a tab; 'C:\temp' is verbatim), and ${VAR} is not interpolated — it stays literal, because a value that could name another variable would be a way around the allowlist you just set.

A file the parser can't read stops the run and says why. See Read environment variables, and dotenv.parse in the builtins reference for reading a .env file as ordinary data with no grant.

--allow-cluster[=SECRET]

Grant the script the cluster capability — a small cluster-wide config table that converges across nodes, via the cluster module. Without it, import cluster is a compile error (default-deny). SECRET is the cluster's shared MAC secret: every node meshing the same table must present the same one, and a mismatch is refused rather than trusted. A bare --allow-cluster uses the empty secret, which is fine for local dev and demos but not for anything real. The actors a script creates hold the grant too, with the same secret, so a handler opens and writes the same tables as the top-level code.

$ scua --allow-cluster=hunter2 config.scua
$ scua --allow-cluster config.scua           # empty secret (local/demo)

See Share config across a cluster for the table surface and the multi-node story.

#--allow-udp[=PEERS]

Grant the script the udp capability — sending and receiving UDP datagrams via the udp module. Without it, import udp is a compile error (default-deny). This is a separate grant from --allow-net, never implied by it: a datagram can be aimed at a third party, so unscoped UDP is its own authority.

The grant names peers, not just "yes". --allow-udp=1.1.1.1:53,10.0.0.0/8:7946 is a comma-separated allowlist of ADDR[:PORT] or CIDR[:PORT]. A host is resolved and then the resulting address is checked, so a name that resolves outside the allowlist is refused. A bare --allow-udp allows any peer — keep it to a host you trust.

Receiving from anyone (udp.bind) additionally needs --allow-serve, since hearing from strangers is listen authority on top of datagram authority.

$ scua --allow-udp=1.1.1.1:53 dns_query.scua

See Send datagrams with UDP for the socket surface and the guardrails.

#--udp-max-payload=N

Raise the datagram payload cap above its 1200-byte default. That default is the portable-safe floor: a fragmented datagram is all-or-nothing, so fragmenting multiplies your loss. Raise it only when you know the path can carry larger packets. A send over the cap is a loud Error, never a silent truncation.

#--allow-tty

Grant the script the tty capability — asking the person at the keyboard a question, via sys.readline() and sys.readpassword(). Without it those two don't exist to call (default-deny), because a terminal is where a human types passwords, so reading one is authority like fs or net rather than something every script gets for free. An actor never inherits it.

$ scua --allow-tty greet.scua

This is a different thing from piped input: sys.stdin() reads a payload piped into the script and needs no grant, and neither flag nor grant turns one into the other. In a capability file the key is tty = true (or tty = false to refuse it outright, which holds even under --allow-all).

See Ask the user a question for the prompt shapes, the end-of-input rule, and why not to mix the two ways of reading input.

#--allow-sandbox

Grant the sandbox capability: the program may compile and run SCUA source strings in isolated, capability-free partitions (import sandbox).

This grants the authority to create a sandbox. The sandboxed code itself receives nothing — not this capability, and not any other one the run holds, so a sandbox cannot reach the filesystem, the network, the environment or the clock, and cannot create sandboxes of its own. There is no flag that relaxes that. The actors a script creates hold the grant too, so a handler can create a sandbox as the top-level code can; a program that embeds SCUA decides for itself whether its actors do.

Use it when your program runs code that came from somewhere else: a player's mod, a rule someone typed in, a tool a model generated. See Run code in a sandbox.

#--io=<async|blocking>

Choose how the script's capability I/O (fs, http, exec and net calls) runs. The default is --io=blocking: each call blocks until it returns, which is exactly right for a straight-line script. --io=async lets a call that has to wait park instead, so the rest of the program runs meanwhile: other tasks started with spawn, other partitions, and the other arms of a wait_all/map_all. fs and exec calls, https:// requests and http.open/http.read run on an offload pool; net.dial, net.accept, net.read and net.write wait for their sockets on a single network poller thread and do the I/O themselves when the socket is ready, and a host name is looked up on one of two shared resolver threads (a dial returns Error("net.dial: the resolver is busy") when 64 lookups are already waiting). A TLS handshake still runs in place; reads and writes on the encrypted connection park like any other.

An http.get, http.post or http.request to a plain http:// URL waits on the network poller the same way, so a thousand calls waiting on a slow service hold a thousand sockets and no threads. Its connections are kept open between calls and reused, up to --http-max-connections=N per host and port (default 256); a call that finds them all busy waits for one to come free, within its own time limit. A connection is reused only after its whole response has been read and only when the response allows it; one left idle for 4 seconds is closed. When a call gives up on a server that has not answered yet, the connection stays open long enough to read and discard the late answer (up to 10 seconds), then goes back to be reused, so a slow server costs calls waiting rather than new connections.

This is an optimization flag, not a requirement. The values are identical either way — for a program that finishes in both modes and whose calls finish within their time limits — and so is the all-settled behaviour of wait_all. What may differ is the order of lines printed by tasks that run at the same time, because they resume in the order their I/O finishes. Two things stay as they are under the flag: an http.serve handler still waits in place for its calls, and calls on one net connection in the same direction still complete in the order they were made. Write the code once and add the flag when overlap pays off.

Three limits keep parked net calls bounded, each with a flag that raises it and an error that names the flag: --net-max-parked=N (calls one partition may have waiting, default 1024), --net-max-queued-write=BYTES (unsent bytes that parked writes may hold on one connection, default 16 MiB), and --net-max-registrations=N (sockets watched at once, default 16384). That last one is a single limit for the whole process: the connections an http.serve holds open count against it as well as the sockets parked calls wait on, so a server near it accepts fewer connections and a parked call past it is refused. With --mem-stats, a [net] line reports the high-water mark of each, and of the watched sockets both together and split into the two kinds. Calls still waiting when the program exits, or when it is stopped with Ctrl-C or SIGTERM, are named on stderr: scua: 2 net calls still waiting at exit (net.read db.internal:5432 …). --log=debug adds a line each time a net call parks, resumes or queues behind another; a call that has waited 30 seconds says so at info, which is on by default.

$ scua --io=async --allow-net=api.example.com fetch_all.scua

#--workers=N

Run partitions on N threads at once. The default is --workers=auto: one thread per full-speed core, at most 8. auto leaves out the efficiency cores on Apple silicon and respects a container's CPU limit on Linux, so a program in a two-CPU container gets two threads however many the machine has. Partitions that are waiting on the network or busy computing don't hold each other up: three partitions that each make a 300 ms network round trip finish in about 0.3 s rather than 0.9 s.

The price is a repeatable order. Each partition still handles one message at a time, and messages from one sender still arrive in the order they were sent, but lines printed by different partitions can come out in a different order from run to run. --workers=0 gives that order back: the partitions take their turns one at a time on the main thread, and the same program prints the same lines in the same order every time. Today --workers=1 does the same; a later release gives 1 a thread of its own beside the main script, so write 0 when the order is what you want. See Several cores by default; one core is repeatable.

It spreads partitions across cores, not the tasks inside one. Tasks started with spawn in a single partition still take turns, so to run several network calls side by side, give each its own partition. A program with no partitions is unaffected.

$ scua --workers=4 --allow-net=db.internal pool.scua
$ scua --workers=0 report.scua      # the same lines in the same order, every run

To choose without changing the command line, set SCUA_WORKERS to a number or auto. The flag wins when both are given. scua test, scua debug and --fast always run the partitions one at a time: they ignore SCUA_WORKERS, scua debug and --fast refuse --workers=N for any N but 0, and scua test doesn't take the flag.

Any count is accepted. A count above the cores the process can run at full speed prints one line when the program starts: the extra threads cost memory and add no speed.

$ SCUA_WORKERS=0 scua server.scua

--locale=<tag|host> and --tz=<offset|host>

Set the system locale and timezone the script reads via sys.locale() and sys.timezone(). Both are neutral by default — with no flag, sys.locale() is "und" (undetermined) and sys.timezone() is 0 (UTC) — so a run is reproducible across machines unless you say otherwise.

  • --locale=de-DE sets the locale tag (any BCP-47 string; stored verbatim).
  • --tz=+02:00 sets the UTC offset; accepts ±HH:MM, ±HH, and UTC/Z. Non-hour offsets work: --tz=+05:45. IANA zone names (Europe/Berlin) are not supported — use an offset.
  • --locale=host / --tz=host read the real device setting (from LC_ALL/LC_CTYPE/LANG and TZ). This is the one explicit, opt-in source of nondeterminism — like --fast for the clock. --tz=host needs a numeric TZ offset; if the host only has a zone name, pass --tz=±HH:MM instead.
$ scua app.scua                              # locale=und, tz=0 (UTC)
$ scua --locale=de-DE --tz=+02:00 app.scua   # pin both (great for tests)
$ scua --locale=host --tz=host app.scua      # read the device

The timezone offset feeds the date layer: time.now() and time.of(...) default to it. See Set the locale and timezone and Dates and times.

#--mem-stats and --gc-threshold=BYTES

Two diagnostics. They exist to answer "where is my memory going", not to tune a deployment, and neither is a stability promise — treat them the way you would --jit-stats.

--mem-stats prints one line to stderr as the script exits:

$ scua --mem-stats build.scua
[mem] live=25291648 peak-heap=25291648 live-after-gc=8514416 peak-live-after-gc=8514416 \
      final-live=16903024 peak-gc-transient=17087088 external=0 alloc-total=25349896 \
      gc=1 gc-copied=8514416 demotions=0 partitions=1 all-live=25291648 gc-pause-max-us=2210 \
      trims=0 trim-pause-max-us=0
field meaning
live arena bytes plus external buffer bytes held right now — including garbage that hasn't been collected yet
peak-heap the run's high-water for that figure; again, occupancy, not live data
live-after-gc what was actually live immediately after the most recent collection (0 if none ran)
peak-live-after-gc the largest such live set over the run
final-live the live set after one collection forced once your script has finished — the honest "what did this program end up holding"
peak-gc-transient the most memory a single collection needed for both spaces at once
external bytes in large element-typed buffers, which live outside the arena
alloc-total every byte ever allocated, garbage included — the churn, which live can't show
gc / gc-copied collections, and the live bytes they copied
demotions typed arrays that fell back to boxed storage
partitions / all-live how many partitions exist, and their summed current occupancy
gc-pause-max-us the longest single collection, in microseconds, of any partition this run collected
trims / trim-pause-max-us collections of partitions that had sat idle with garbage while others kept working, and the longest of them

The distinction that matters most: live and peak-heap include garbage. SCUA allocates by bumping a pointer, so until a collection runs it genuinely cannot tell live data from dead. If you want "how big is my data", read final-live. If you want "how much did the process have to hold", read peak-heap. Every field except partitions/all-live, gc-pause-max-us and the two trim fields describes the main partition only — each actor has its own.

A program stopped by a signal prints no [mem] line, and no --jit-stats lines either. The line is written as the program finishes, whether it ends normally or with an error; Ctrl-C, or kill sending TERM, ends the process before that point. A server stopped that way finishes its requests and its program carries on to its end, so the line is printed then; to watch a server while it runs, use --serve-stats-every=N, which prints the arena bytes each request allocated and the collection rate every N requests.

--gc-threshold=BYTES sets how much a program may allocate on top of what it is already holding before the collector runs (default 8 MiB). Lower it and a script collects sooner and holds less; raise it and it runs longer between collections and holds more. The tradeoff is monotone in the direction you'd expect, so it is a reasonable dial to turn as well as to look at:

$ scua --mem-stats --gc-threshold=256K build.scua

Note this is headroom above the live data, not a total. A program holding 200 MB with the default 8 MiB floor collects at about 208 MB, not at 8 MiB — otherwise a program whose data is simply large would be collected continuously and reclaim nothing each time.

The 8 MiB floor is the main program's. Each partition collects on its own, from a floor of 128 KiB (less while its memory is still small), or --gc-threshold if that is lower: a program can have thousands of partitions, and 8 MiB of garbage in each one would add up to gigabytes. Above the floor a partition collects when it has allocated as much again as it was holding, the same rule as the main program, so a partition holding a lot of data is not collected any more often than before.

#Serving: limits, timeouts and stopping

These shape how http.serve treats clients and how it stops. All of them are only meaningful with --allow-serve. A duration is written 10s, 500ms or 2m; a bare number is milliseconds, and 0 turns that limit off. Serve HTTP requests shows them at work.

Flag Default Meaning
--serve-timeout=DUR 30s How long one request may take, from its first byte to the end of the handler. A handler still running then gets a 503 and the connection closes; the server carries on
--serve-header-timeout=DUR 10s The request line and headers must arrive within this of their first byte; a connection that sends nothing is closed after the same time
--serve-body-timeout=DUR 30s The body must arrive within this of the headers, and after its first 10 seconds at 1 KiB a second or more on average
--serve-write-timeout=DUR 30s A response the client stops taking for this long is given up on and the connection closed
--serve-idle-timeout=DUR 75s A kept-alive connection with nothing to do is closed
--serve-max-conns=N 10000 Connections held at once, fewer when the open-file limit is lower; more wait in the operating system's queue
--serve-max-buffered=BYTES 64M Request bytes received but not yet a whole request, across every connection (an eighth of the container's memory limit when it has one); past it the slowest connections are closed first
--serve-drain-delay=DUR 0 After Ctrl-C or SIGTERM, keep accepting and answering this long before stopping
--serve-grace=DUR 25s Then wait this long for requests in flight; whatever is left is abandoned and the run exits 75
--serve-stats=DUR off A line of request counts, latency and connections every DUR
--serve-stats-every=N off Every N requests, the arena bytes each one allocated and the collection rate (a diagnostic)
--serve-log off One JSON line per request on stderr

A request that runs out of time while it is still arriving gets 408 Request Timeout. A request whose handler runs past --serve-timeout gets 503:

$ scua --allow-serve=127.0.0.1:8080 --serve-timeout=2s --serve-debug slow.scua
HTTP/1.1 503 Service Unavailable
handler exceeded the request deadline (--serve-timeout)

The explanation in the body only appears with --serve-debug; without it the 503 is bare, like every other error a client sees. Granting serve arms the request deadline — you turn it off rather than on, because a server runs work chosen by someone else over and over. It is measured against the real clock, so it is not armed under --fast or scua test, where the clock fast-forwards.

--serve-stats needs a unit: a bare number used to mean "every N requests", which is --serve-stats-every=N now, and is refused rather than read as milliseconds.

The first stop signal drains; a second one ends the process at once. SIGHUP does not stop a server. The run exits 0 after a clean stop and 75 when the grace ran out with requests abandoned, unless a fault or sys.exit chose the status first.

#--max-ops=N and --max-mem=BYTES

Bound what an untrusted script may consume. Both take a count with an optional k/m/g suffix (--max-ops counts in 1000s, --max-mem in 1024-byte units), so --max-ops=5m is five million and --max-mem=64M is 64 MiB. These are the CLI equivalents of the op_budget and mem_cap arguments an embedding host passes to scua_eval.

--max-ops caps the steps any single turn may take, and stops the turn at its Nth. When a script reaches it, an uncatchable kill-switch stops the run, naming the budget it used and how to change it. A loop that never yields is bounded in CPU, so it can't spin forever:

$ scua --max-ops=100k spin.scua
spin.scua:2: this turn used its whole CPU budget of 100000 steps, set by `--max-ops`. Raise it, or remove the budget with `--max-ops=none`

What counts as a step. Exactly two things, one step each, added into one total:

  • a function call — to a function in your script, in a module you imported, or in a package; to a built-in or host function; a method call; a pcall. Where the function is does not matter.
  • a loop iteration — each time a while, for or comprehension goes round again.

Nothing else counts. Straight-line code between calls is free, and so is the work a built-in does inside its one call: hashing a megabyte and adding two numbers are each one call. (A regex search is the exception: it counts its own matching steps, so a pathological pattern is bounded too.) A few built-ins are compiled into single instructions rather than calls, and cost nothing: len, push, pop, remove, contains, slice, to_int, and the vector and quaternion operations. The count is the same with the JIT on or off.

It is a safety budget, not a cost model. It exists so that runaway work (an infinite loop, a runaway recursion) is always stopped, and it does that whichever path the work takes. It does not weigh work, so don't use it to compare the cost of two code paths or as a stand-in for time: two paths that make the same calls and loop the same number of times cost the same steps however differently long they take. A run that takes S steps finishes under --max-ops=S+1; that is the number to raise a budget from if a genuine workload hits it.

Without the flag there is no CPU limit. The rule is one sentence: a budget you set kills; the built-in one does not. There is still a built-in ceiling of 100,000,000 steps, but reaching it now refills it and carries on, so a long computation simply finishes — a loop of a few hundred million iterations used to be stopped a third of the way through and no longer is. Type --max-ops=N and you get a real kill-switch, exactly as before.

That split is deliberate. Writing a tool at a command line, you are not the person the limit was for; running someone else's script, you are, and then you say so. The same rule holds everywhere a budget is chosen for you: a sandbox mod's, and an embedding host's op_budget, both still kill.

--max-ops=0 is refused rather than obeyed: a budget of zero would stop the script on its first step, which nobody means when they type it. For no limit, write none.

The line in the message is approximate under the JIT. With --jit=on the reported line is the enclosing call rather than necessarily the exact step that ran the budget out, because the JIT charges a loop iteration's cost up front and refunds what it skips, where the interpreter charges call by call. Whether the budget trips, and what the run prints, are identical either way; only the line can differ. Re-run with --jit=off if you need it pinned to the exact step.

The budget is per turn, so it also caps each actor or coroutine turn the script spawns — and it is refilled at the top of each one. That means it bounds how much work any single turn may do, not how long a program may run: a loop that waits or polls yields between turns and is not bounded by this at all. Use a timeout on whatever the loop is waiting for, or --max-total-ops below. Creating an actor is one call like any other, so the budget limits how many actors one turn can create but not how many a program creates over many turns. --max-mem is what limits that, because every actor's memory counts against it.

To bound the whole run, use --max-total-ops=N. It counts the same steps as --max-ops, but for the run as a whole: every turn of the main script and of its partitions adds to one total, and a wait does not start it again. A tool that loops with a sleep between rounds, which --max-ops never stops, stops once it has taken N steps in all, with the same exit status (6):

$ scua --max-total-ops=600k poll.scua
poll.scua:4: the run used its whole budget of 600000 steps, set by `--max-total-ops`. Raise it, or remove the budget with `--max-total-ops=none`

The two flags combine: --max-ops still stops a single turn that runs away, and --max-total-ops stops a run that does too much over many turns. A sandbox's code runs under the limits its host gave it and is not counted. The count is checked every few thousand steps, so a run may go a little past N before it stops. Compiled code checks the total that often too, which makes a heavily recursive program somewhat slower while the flag is set.

The kill-switch is uncatchable on purpose: try/rescue and pcall don't see it, because a handler would resume with the budget still exhausted and trip again immediately — a script could then loop forever catching its own limit. The call-depth limit is different and is catchable, because unwinding to the handler frees the call frames that breached it; see Running out of stack.

--max-mem caps the extra live memory the script may hold beyond what it starts with. Over the cap, the run stops with memory limit exceeded instead of growing without bound:

$ scua --max-mem=64K hog.scua
scua: hog.scua: memory limit exceeded

The cap is on the live set (it's checked after garbage is reclaimed), so ordinary churn that frees as it goes won't trip it — only genuinely holding more than the cap does.

The process can use up to about twice the live set. The collector moves what is still live into fresh space and then frees the old, so for the moment of a collection both copies exist, and garbage waiting for the next collection takes room too. Measured: 36 MB of live data with steady churn peaked at 78 MB of resident memory, and 72 MB at 150 MB. So --max-mem bounds what the script holds, not the process's peak: if a container or a host gives the process a hard limit, leave about twice the --max-mem value. --mem-stats reports both (peak-live-after-gc against peak-heap).

The cap is one total for the whole program, actors included. Every actor is a partition with memory of its own, and that memory counts against the same cap as the top-level code's, from the moment the actor is created. Creating an actor that would take the program over the cap stops the run there, with the same memory limit exceeded, so a script cannot get round the cap by spreading its data across many actors, or by creating actors until the machine runs out:

$ scua --max-mem=1M spawn-many.scua
scua: spawn-many.scua: memory limit exceeded

Each actor starts out holding some memory before it has done anything, so a program that creates actors needs a cap with room for them. Check with --mem-stats, whose all-live figure is the total across every partition. With --workers above 1, two actors running at the same moment may each grow a little before either sees the other's growth, so the total can briefly go slightly past the cap before the run stops.