# Subtype: full documentation Source: https://subtypelang.com. One file, every page, plain Markdown. Language: scripts that declare what they may touch in a `needs` header; `sub check`, `sub explain`, `sub manifest`, `sub build`, `sub run`. # Introduction Coding agents run shell commands and tools all day, and safety today is guesswork from command strings. Subtype turns that around. A Subtype program starts with a `needs` block that lists everything it may touch. Nothing outside that list works, and a reviewer can read the list in five seconds. ## What it is for **Safe scripts and tools for AI agents**, and a safer replacement for shell and glue scripts. The 30-second demo: run a script, watch it ask for exactly the files and commands it needs, deny one thing, and see it fail cleanly. ## The four ideas 1. **Permissions first.** A plain list at the top: files, commands, hosts. Missing means none. 2. **Less is a subtype.** A smaller permission is a subtype of a bigger one. A function that needs `read [./src]` accepts `read [.]`, never the reverse. 3. **Enforced twice.** The compiler checks every call against `needs`. The runtime (WebAssembly with WASI) has no way out even if the compiler had a bug. 4. **Readable by people and agents.** It reads like a short to-do list, and `sub manifest` gives tools a JSON permission set. The mark is a **gate within a gate**: a smaller door inside a bigger door. The inner door is the permission your script was actually given. ## Who it is for - People who let an agent write scripts and want to approve them by meaning, not by reading code. - Tools such as Centcom that run agent tasks and need a permission prompt that is exact. - Anyone who shares scripts and wants the reader to know what they will do before they run them. --- # Install ## One line ```sh curl -fsSL https://subtypelang.com/install.sh | sh ``` It downloads the latest `sub` for your system, checks its SHA-256 against the published `SHA256SUMS`, puts it in `~/.local/bin` (no `sudo`) and tells you if that folder is not on your `PATH`. At the end it asks whether to install the **Claude Code skill** as well, so your agent writes scripts in Subtype. Read the script first if you like: [`/install.sh`](/install.sh) is about 120 lines of plain `sh`. ## Options Pass options after `sh -s --` when you pipe: ```sh curl -fsSL https://subtypelang.com/install.sh | sh -s -- --skill ``` | Option | Does | | --- | --- | | `--skill` | also install the Claude Code skill, without asking | | `--no-skill` | do not install it, and do not ask | | `--skill-dir DIR` | where the skill goes (default `~/.claude/skills`, or `$CLAUDE_SKILLS_DIR`) | | `--version X` | install a specific version instead of the latest | | `--dir DIR` | where `sub` goes (default `~/.local/bin`, or `$SUB_INSTALL_DIR`) | | `--uninstall` | remove `sub` and the skill | | `--help` | show the options | The skill is copied to `~/.claude/skills/subtype`. Nothing else in your Claude settings is changed. Start a new Claude Code session to pick it up. Details: [For AI agents](/agents/). ## What is available Latest version 0.0.1 (built 2026-10-13 from commit b7297db6fbe8). | System | Download | Checksum | | --- | --- | --- | | x86_64-unknown-linux-musl | https://subtypelang.com/releases/0.0.1/sub-0.0.1-x86_64-unknown-linux-musl.tar.gz | sha256 e67e0c851fefb1ba4ba5d94430d9a8d0fe993b5ae82f7402558836ea72d1650f | | Claude Code skill | https://subtypelang.com/releases/0.0.1/subtype-skill-0.0.1.tar.gz | sha256 fcfc2dd05bc430cff017402189d2819c074c1176d08e8397cd66ace644c5f4cb | All checksums: https://subtypelang.com/releases/0.0.1/SHA256SUMS Right now only **Linux on x86_64** has a prebuilt binary (a static `musl` build that runs on any Linux distribution). macOS, Linux on ARM and Windows are not built yet: the installer says so and points you to the section below. This is an early preview and the language is still changing. ## Install by hand ```sh V=$(curl -fsSL https://subtypelang.com/releases/latest/VERSION) curl -fsSLO https://subtypelang.com/releases/$V/sub-$V-x86_64-unknown-linux-musl.tar.gz curl -fsSLO https://subtypelang.com/releases/$V/SHA256SUMS grep x86_64-unknown-linux-musl SHA256SUMS | sha256sum -c - tar -xzf sub-$V-x86_64-unknown-linux-musl.tar.gz install -m 755 sub-$V-x86_64-unknown-linux-musl/sub ~/.local/bin/sub sub --version ``` ## Build from source You need [Rust](https://rustup.rs). The first build compiles Wasmtime and takes a few minutes. ```sh git clone && cd subtype cargo build --release # target/release/sub cargo build --no-default-features # without Wasmtime: check, explain, manifest and build still work ``` ## Check that it works ```sh sub --version sub init demo && cd demo sub explain main.sub sub run main.sub ``` ## Troubleshooting - **`sub: command not found`**: `~/.local/bin` is not on your `PATH`. Add `export PATH="$HOME/.local/bin:$PATH"` to your shell profile and open a new terminal. - **`checksum mismatch`**: nothing was installed. Try again; if it persists, tell us. - **`fetch` fails with a certificate error**: `sub` trusts your system's certificate bundle (`$SSL_CERT_FILE` or the usual paths). Install `ca-certificates`. - **Uninstall**: `curl -fsSL https://subtypelang.com/install.sh | sh -s -- --uninstall`. --- # Quickstart :::note[Design draft] The compiler is being written. This page shows the intended workflow; see `compiler/README.md` in the repository for what runs today. ::: Create `fix.sub`: ```sub title="fix.sub" needs [ read [./src], write [./src], run ["npm test", "git commit"], ] let t = run("npm test") if (t.failed) { let path = t.failing_file write(path, fix(read(path), t.output)) run("git commit -am 'fix failing test'") } ``` Then: ```sh sub check fix.sub # parse and check the permissions sub explain fix.sub # what can this script touch, in plain words sub run fix.sub # shows the needs block and asks before running ``` `sub explain` prints: ```text fix.sub can read files under ./src write files under ./src run "npm test", "git commit" fix.sub cannot use the network, read other folders, run other commands ``` ## What happens when a script goes too far Add `run("git push")` and `sub check` stops with the line, the column and the fix: ```text fix.sub:9:3 error: this runs "git push" but needs only allows "git commit" 9 | run("git push") | ^^^^^^^^^^^^^^^ fix: add "git push" to run [...] in needs, or remove the call ``` ## Next - [A tour in five minutes](/start/tour/) - [The needs header](/language/needs/) --- # A tour in five minutes Brackets each have one job: | Bracket | Job | | --- | --- | | `[ ]` | sets and lists: permissions, lists, paths | | `{ }` | blocks of code and records; `{name}` inside a string interpolates | | `( )` | calls and grouping; the condition of `if`, `for`, `match` | | `< >` | type parameters only, as in `Secret` | ```sub needs [ read [./src], run ["npm test"] ] // 1. the file starts with needs let name = "world" // 2. let is fixed, mut changes mut count = 0 let msg = "hello {name}" // {} interpolates let files = [./a.ts, ./b.ts] // [] is a list; paths are bare let cfg = { port: 8080, host: "x" } // {} is a record let t = run("npm test")? // 3. calls; ? passes a failure up let n = read(./count.txt) or { "0" } // or { } handles it here if (t.failed) { log("failing") } else { log("ok") } for (f in files) { log(f) } let token: Secret = env("TOKEN") // 4. a secret can be used, never printed ``` Five things to remember: 1. The file starts with `needs [ ... ]`. 2. `let` is fixed, `mut` is changeable. 3. Paths are written bare: `./src`. 4. Failure is a value: `?` passes it up, `or { }` handles it. 5. A secret can be used but never printed. Statements end at the newline: no semicolons. Comments start with `//`. --- # The needs header It must be the first thing in a file. A file without one has no permissions at all. ```sub needs [ read [./src, ./tests], write [./src], run ["npm test", "git add", "git commit"], net [], ] ``` - Keys: `read`, `write`, `run`, `net`, `env`, `clock`, `random`. - A missing key means none. `net []` says it out loud, and a reviewer sees that an empty bracket means no network. - Paths are bare (`./src`) and stay inside the folder the script was started in unless the person grants more. - Trailing commas and free whitespace inside brackets are fine. :::tip `sub explain` and the run prompt print the block back in plain words, so a person approves meaning, not syntax. ::: ## Reading it | Line | Means | | --- | --- | | `read [./src, ./tests]` | read files and list folders under these two paths | | `write [./src]` | create, change and delete files under `./src` | | `run ["git commit"]` | start `git commit`, nothing else | | `net []` | no network at all | See [Permissions](/reference/permissions/) for every key and the subset rule. --- # Values and calls ```sub let tests = run("npm test") // a result: .failed .output .code let name = "world" let msg = "hello {name}" // {} inside a string interpolates let files = [./a.ts, ./b.ts] // a list let cfg = { port: 8080, host: "x" } // a record mut count = 0 // mut can change ``` - `let` is fixed; `mut` is changeable. - Strings use double quotes with `{name}` interpolation. - Paths are a real type, written bare. - Statements end at the newline: no semicolons. Comments start with `//`. - Whitespace inside brackets is free and trailing commas are allowed. --- # Control flow and data Everything on this page is in one script that `sub check` accepts and `sub run` prints the output of (shown at the end). ## Loops and ranges ```sub let mut total = 0 for (i in 0..5) { if (i == 3) { continue } total += i } let mut n = 10 while (n > 0) { n -= 3 if (n < 4) { break } } ``` `0..5` stops before 5; `0..=5` includes it. `continue` and `break` work in `for` and `while`. `+=` and `-=` change a `let mut` value. ## Destructuring ```sub let (a, b) = (1, 2) let [first, second] = [10, 20] let { name, port } = { name: "sub", port: 8080 } ``` Tuples, lists and records can be taken apart in `let`, `for` and `match`. ## match: patterns and guards ```sub fn classify(n) { match (n) { 0 => "zero", x if x < 0 => "negative", 1..=9 => "small", _ => "big" } } ``` A pattern can be a literal, a range, a name, a constructor or `_`. `pattern if condition =>` is a guard. `match` must still cover every case. ## if let ```sub let maybe = find([1, 2, 3, 4, 5], fn(x) { x > 3 }) if (let Some(v) = maybe) { print("found", v) } ``` The condition stays in parentheses; `let` goes inside them. ## Slices, maps, sets, defaults and formats ```sub let xs = [1, 2, 3, 4, 5] print(xs[1..3]) // [2, 3] let m = #{ "a": 1, "b": 2 } // a map let s = #[1, 2, 2, 3] // a set: #[1, 2, 3] let who = None ?? "anon" // ?? gives a default print("{who:>8}|", 0xff, 1_000, 2e3) // a format spec, hex, underscores, exponents print(r"C:\raw\path") // a raw string ``` `?.` reads a field of an `Option` only when there is one. Strings can be raw (`r"..."`) or triple-quoted. ## The whole script ```sub title="flow.sub" let mut total = 0 for (i in 0..5) { if (i == 3) { continue } total += i } print(total) let mut n = 10 while (n > 0) { n -= 3 if (n < 4) { break } } print(n) let (a, b) = (1, 2) let [first, second] = [10, 20] let { name, port } = { name: "sub", port: 8080 } print(a + b, first + second, name, port) fn classify(n) { match (n) { 0 => "zero", x if x < 0 => "negative", 1..=9 => "small", _ => "big" } } print(classify(0), classify(-4), classify(7), classify(100)) let xs = [1, 2, 3, 4, 5] print(xs[1..3]) let m = #{ "a": 1, "b": 2 } let s = #[1, 2, 2, 3] print(m, s) let maybe = find(xs, fn(x) { x > 3 }) if (let Some(v) = maybe) { print("found", v) } let name2 = None ?? "anon" print("{name2:>8}|", 0xff, 1_000, 2e3) print(r"C:\raw\path") ``` ```text 7 1 3 30 sub 8080 zero negative small big [2, 3] {a: 1, b: 2} #[1, 2, 3] found 4 anon| 255 1000 2000.0 C:\raw\path ``` --- # Functions ```sub fn patch(path: Path, why: String) -> Result[Patch] { ... } ``` A function in a script can only use what the script's `needs` allows. A library function can declare its own, smaller set, and the compiler checks that the caller's set covers it: ```sub fn load(p: Path) needs [read [p]] -> Config { ... } ``` That is the subtype rule in use: the caller must hold a permission at least as big as the function asks for, so asking for less is always fine. --- # Types Types are inferred, so a script rarely writes them. They show up where they help: a function's signature, a refined type, a secret. ## Enums and exhaustive match ```sub title="enum.sub" enum Shape { Circle(Int), Rect(Int, Int), Empty } fn area(s: Shape) -> Int { match (s) { Circle(r) => 3 * r * r, Rect(w, h) => w * h, Empty => 0 } } print(area(Rect(3, 4))) ``` `match` must cover every case. Leave one out and `sub check` says which: ```text error[E0642]: this `match` does not cover: Empty --> fail_enum.sub:4:3 | 4 | match (s) { | ^^^^^^^^^^^ not every case is covered = help: add those cases, or end with `_ => ...` to cover the rest ``` Exhaustiveness covers enums, `Option`, `Result` and `Bool`. ## Refined types A refined type is a base type with a condition. A literal that breaks the condition is a compile error. A value that arrives at run time is checked where it is converted. ```sub title="refined.sub" type Port = Int where 1..=65535 error ConfigError error NotNumber <: ConfigError error BadPort <: ConfigError fn parse_port(text) -> Result { let n = int(text) or { return NotNumber("not a number") } Port(n) or { return BadPort("out of range") } } match (parse_port("8080")) { Ok(p) => print("port", p), Err(BadPort) => print("too big or too small"), Err(ConfigError) => print("some other config problem") } match (parse_port("0")) { Ok(p) => print("port", p), Err(BadPort) => print("too big or too small"), Err(ConfigError) => print("some other config problem") } ``` ## Failure families `error Name <: Family` makes `Name` a subtype of `Family`. A handler can name the family or the exact case. `OutOfRange` and a few others are built in. ## Secrets `Secret` is not a subtype of `T`. A secret cannot reach `print`, `log`, a string or a record's printout: ```text error[E0302]: a secret cannot be passed to `print` --> fail_leak.sub:3:7 | 3 | print(token) | ^^^^^ this is a secret = help: a secret can only be compared with secret_eq(a, b) or passed to your own function that takes a `Secret` ``` ## Records, lambdas, options and `defer` ```sub let record = { name: "sub", port: 8080, ratio: 7 / 2.0 } let moved = { ..record, port: 9090 } // a copy with one field changed let total = fold([1, 2, 3], 0, fn(sum, n) { sum + n }) let found = find([1, 5, 8], fn(n) { n > 4 }) // an Option print(unwrap_or(found, 0)) defer print("done") // runs when the enclosing function or script ends ``` `List`, `Option` and `Result` are generic. Your own functions are generalised by inference but cannot be annotated with type parameters yet. --- # Errors There are no exceptions. A failure is a value. ```sub let t = run("npm test")? // pass a failure up to the caller let n = read(./count.txt) or { "0" } // handle it here ``` An unhandled failure stops the script. Errors come in families. `NotFound` and `Expired` are both a `SessionError`, so a handler can catch the family or the exact case, and `match` tells you if you forgot one. --- # Secrets ```sub let token: Secret = env("GITHUB_TOKEN") // needs env ["GITHUB_TOKEN"] log(token) // does not compile "{token}" // does not compile ``` `Secret` is **not** a subtype of `T`. It can be passed to an approved operation (send it in a header, compare it), but it cannot reach a log or a string by accident. --- # Narrowing and demote ```sub let src = read[./src] // a handle to just that folder helper(demote(src, read[./src/lib])) // hand over less power ``` A smaller bracket is visibly inside a bigger one: that is the subtype idea in the syntax. `demote` gives a function less than you have, and there is no way back without a token. --- # Modules and tests `sub init` makes a small project: ```text main.sub lib/text.sub ``` ```sub title="main.sub" needs [ clock ] use "./lib/text.sub" let started = now() print(greet("world")) print(shout("subtype")) print("took", now() - started >= 0) test "greet says hello" { assert_eq(greet("sub"), "hello, sub") } ``` ```sub title="lib/text.sub" fn greet(name: String) -> String { "hello, {name}" } fn shout(text: String) -> String { upper(text) + "!" } test "shout makes it loud" { assert_eq(shout("hi"), "HI!") } ``` ## The subtype rule across files A library function that touches the outside says so with its own `needs`. The script that calls it must allow that. A call that needs more than the script holds is an error, with the file and the line: ```sub fn load(p: Path) needs [read [p]] -> String { read(p) or "" } ``` ## Tests ```sh sub test main.sub --yes ``` ```text ok greet says hello ok shout makes it loud 2 passed, 0 failed ``` Test blocks live next to the code they test, in the script or a library. `sub test` runs on the interpreter, not on Wasmtime. :::caution[Today] Library names share one flat namespace, so two libraries cannot both define `parse`. There is no `use ... as name`, no private names and no package manager yet. ::: --- # Tasks ```sub title="tasks.sub" fn work() { let a = spawn(fn() { print("a runs") 1 }) let b = spawn(fn() { print("b never runs") 2 }) let r = await(a) or 0 r + 10 } print(work()) ``` ```text a runs 11 ``` A task is lazy and structured. `spawn` makes it, `await` runs it and gives its result, and a task nobody awaited is cancelled when its function ends. `b never runs` is never printed. --- # Permissions | Key | Meaning | Enforced as (WASI) | | --- | --- | --- | | `read [paths]` | read files and list folders under these paths | read-only preopened directory | | `write [paths]` | create, change and delete files under these paths | read-write preopened directory | | `run [patterns]` | start these programs | host function: checked, then spawned with a limited environment | | `net [host:port]` | connect to these hosts | host-mediated socket, allow-listed | | `env [names]` | read these environment variables | passed in explicitly | | `clock`, `random` | the time, random numbers | handles granted or withheld | ## The subset rule Set A is a subtype of set B when everything A allows, B allows too. - `write [./src]` is smaller than `write [.]`. - `run ["git status"]` is smaller than `run ["git"]`. - `net []` is smaller than everything. A function needing A can be called by anyone who holds B. A call that needs more than the script holds is a compile error, with the line and the fix. --- # Command patterns A `run` entry is a program plus an optional argument pattern. | Pattern | Allows | | --- | --- | | `"git status"` | exactly `git status` | | `"git commit -m *"` | `git commit -m` with any message | | `"git"` | git with any arguments (broader, and shown as such) | :::caution[Open] The exact pattern language (globs, argument patterns, quoting) is still being designed. ::: --- # The sub command | Command | Does | | --- | --- | | `sub check file.sub [--strict]` | find problems; `--strict` also fails on warnings | | `sub explain file.sub\|file.wasm [--json]` | what the script can do, in plain words (or as JSON) | | `sub manifest file.sub\|file.wasm [--centcom]` | the permission set and calls as JSON; `--centcom` shapes it for Centcom's approval prompt | | `sub build file.sub [-o out.wasm] [--wasi]` | compile to a WebAssembly module | | `sub run file.sub\|file.wasm [--yes] [--root DIR] [--timeout S] [--max-output B] [-- args]` | show the permissions, ask once, run | | `sub test file.sub [--yes]` | run the `test "name" { }` blocks | | `sub fmt file.sub... [--check] [--stdout]` | the canonical formatter (idempotent) | | `sub init [dir]` | a small project: `main.sub`, a library, a test | Exit codes: `0` done, `1` failed or did not check, `2` usage, `3` a permission was denied while running, `4` the permissions were not approved. ## A good error ```text error[E0201]: `run("git push")` is not allowed by `needs` --> fix.sub:12:3 | 12 | run("git push") | ^^^^^^^^^^^^^^^ this command is not in the run list = help: add `run ["git push"]` to the `needs` block at the top of the file, or remove the call ``` Errors say what is wrong and show the exact fix, in words that both a person and a model can act on. ## The approval `sub run` prints the permission set in plain words and asks. `--yes` approves. `always` saves the set's SHA-256 in `.sub-allow` next to the script, so the same permissions never ask again and different ones do. Without a terminal and without `--yes`, nothing runs. ## Limits `--timeout SECS` (30 for a request, 300 for a program; a program is killed and gives code 124), `--max-output BYTES` and `--fetch-max BYTES` (10 MiB each). ## Language server `sub-lsp` (in `lsp/`) speaks stdio LSP: diagnostics, hover with types and permissions, go to definition and completion for `needs` keys. --- # The standard library Built in, and shared by the interpreter and the WebAssembly backend. A function that touches the outside needs a permission in `needs`. | Area | Functions | | --- | --- | | Effects (need `needs`) | `read`, `write`, `exists`, `list_dir`, `run`, `fetch`, `env`, `now`, `random` | | Text | `len`, `lower`, `upper`, `trim`, `split`, `lines`, `join`, `replace`, `repeat`, `contains`, `starts_with`, `ends_with`, `index_of`, `slice`, `chars`, `str`, `format_time` | | Numbers | `int`, `float`, `floor`, `ceil`, `round`, `sqrt`, `pow`, `max`, `sum` | | Lists | `map`, `filter`, `fold`, `find`, `first`, `last`, `push`, `take`, `drop`, `range`, `reverse`, `sort`, `sort_by`, `all`, `any` | | Maps | `map_new`, `map_set`, `map_get`, `map_has`, `map_keys`, `map_values`, `map_remove` | | Options | `unwrap_or`, `is_none`, `Some`, `None` | | Paths | `join_path`, `path_name`, `path_parent`, `path_stem`, `path_ext` | | Data | `json_parse`, `json_stringify`, `regex_find`, `regex_replace`, `matches` | | Secrets | `secret_eq` | | Tasks | `spawn`, `await`, `cancel` | | Testing | `assert`, `assert_eq`, `panic` | `sub explain` lists the calls a script makes and what allowed each one. --- # WASI mode ```sh sub build script.sub --wasi -o app.wasm wasmtime --dir=. app.wasm -- arg1 arg2 # or Node's WASI, or Wasmer ``` The module imports only `wasi_snapshot_preview1`. The permissions are also added as custom sections (`sub.manifest`, `sub.mode`) for tools to read. | Subtype `needs` | WASI mapping | What WASI cannot enforce | | --- | --- | --- | | `read`, `write` | `path_open`, `fd_read`, `fd_write`, `fd_readdir` below the folders given with `--dir` | which files inside a granted folder; only the module's own check | | `env` | `environ_get` | the runtime passes the whole environment it was given | | `clock`, `random` | `clock_time_get`, `random_get` | WASI cannot say "no clock" | | `run` | none: WASI cannot start programs | the call fails | | `net` | none in preview1 | `fetch` is unavailable | ## What is and is not guaranteed A denied effect is never attempted, because the module's own check runs first. But that check lives inside the module the embedder runs, so someone who modifies the `.wasm` can remove it. Only the default mode, where the `sub` host checks every effect, keeps the guarantee against a tampered module. Use WASI mode when the runtime's own folder and environment grants are the boundary, and `sub run` when Subtype's are. --- # The manifest `sub manifest` emits the permission set as JSON so tools like Centcom can read, compare and approve a script without parsing it. ```json { "needs": { "read": ["./src"], "write": ["./src"], "run": ["npm test", "git commit"], "net": [] } } ``` The output is deterministic, so a saved allow-list can match a manifest by hash. --- # Grammar The full EBNF will live in `compiler/README.md`. This is the shape: ```text file = needs { statement } needs = "needs" "[" { key set "," } "]" statement = let | mut | fn | if | for | match | expr let = "let" name [ ":" type ] "=" expr expr = literal | path | name | call | list | record | expr "?" | expr "or" block call = name "(" [ expr { "," expr } ] ")" list = "[" [ expr { "," expr } ] "]" record = "{" [ name ":" expr { "," name ":" expr } ] "}" ``` --- # How it is enforced Two layers. 1. The **compiler** checks every call against `needs` and rejects a script that asks for more. 2. The **runtime** compiles the program to WebAssembly and runs it on Wasmtime with WASI. A WASI program has no files, network or clock unless the host hands it a handle, so even a bug in the compiler cannot let it touch the machine. Starting a command goes through a host function that checks the `run` patterns first, because WASI cannot start processes itself. ## Why WebAssembly | Target | Sandbox | Build effort | Startup | Portability | | --- | --- | --- | --- | --- | | **WebAssembly (WASI)** | strong, capability-based | medium | fast | one `.wasm` runs anywhere | | JavaScript on Deno | medium, flags | low | medium | needs Deno | | Own bytecode VM | whatever you build | high | fast | one runtime to ship | | Native (LLVM, Cranelift) | none on its own | high | fastest | per platform | | Transpile to Rust or Go | none on its own | medium | slow (compile step) | per platform | ## First version A tree-walking interpreter with run-time checks behind a single `Host` interface, so the WebAssembly backend can reuse the same checks. --- # Using it with agents - Give the agent a short language guide and the example scripts. They fit in a prompt. - Let it run `sub check` in a loop. Errors are worded so a model can fix them. - Approve with the manifest: compare it to a saved allow-list, or show the plain-words `explain` output to a person once. - Centcom can run agent tasks as Subtype scripts and show the permission prompt in its approval flow. :::note[The honest risk] Models write TypeScript and Python better than a new language. The syntax stays close to what they know, and a restricted JSON form of a script can be accepted so tools never have to emit the text syntax. ::: --- # Design **Purpose.** Safe scripts and tools for AI agents, and a safer replacement for shell and glue scripts. **Core ideas.** Permissions as subtypes, refinement types (a port is an integer from 1 to 65535), secrets that cannot be logged, subtyped errors, protocol versions as subtypes, `demote`, and `sub explain`. **Target.** WebAssembly with WASI, run on an embedded Wasmtime. **Syntax.** Bracket-heavy with one job per bracket and a `needs [ ... ]` header, designed to be human friendly first and agent friendly underneath. The repository holds the longer notes: `docs/design.md` for decisions and reasons, `docs/syntax.md` for syntax choices. --- # Examples Every output below was produced by running `sub`; scripts that fail do so on purpose. ## capabilities.sub Hand a helper less than the script has: only the guides folder, not all of ./docs. ```sub // Hand a helper less than the script has: only the guides folder, not all of ./docs. needs [ read [./docs] ] fn show(c, name) { print(name + ":", read_in(c, name) or "(missing)") } let docs = read[./docs] let guides = demote(docs, read[./docs/guides]) show(guides, "intro.md") show(guides, "nope.md") ``` `sub explain` says: ```text capabilities.sub It can: ├─ can read files │ ./docs and everything below it └─ cannot write files, run programs, use the network, read environment variables, read the clock, or use random numbers The calls it makes: line 5 read_in(c, name) depends on a value, checked when it runs ``` ## fail_demote_more.sub ```sub needs [ read [./docs] ] let guides = read[./docs/guides] let everything = demote(guides, read[./docs]) ``` `sub check` says: ```text error[E0222]: `demote` can only hand over less, and `read ./docs` is not inside `read ./docs/guides` --> fail_demote_more.sub:4:18 | 4 | let everything = demote(guides, read[./docs]) | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ this would give more power = help: pick a smaller capability, for example one inside `read ./docs/guides` warning[W0401]: `needs` allows `read` but the script never uses it --> fail_demote_more.sub:1:9 | 1 | needs [ read [./docs] ] | ^^^^^^^^^^^^^ unused permission = help: remove `read` to ask for less error: 1 error found and 1 warning ``` ## fail_denied_run.sub ```sub needs [ run ["git status"] ] run("git status") run("git push origin main") ``` `sub check` says: ```text error[E0201]: `run("git push origin main")` is not allowed by `needs` --> fail_denied_run.sub:4:1 | 4 | run("git push origin main") | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ this command is not in the run list = help: add `run ["git push origin main"]` to the `needs` block at the top of the file, or remove the call error: 1 error found ``` ## fail_function_needs.sub ```sub needs [ read [./cfg] ] fn load(p) needs [read [p]] { write(p, "oops") } fn wide() needs [read [./elsewhere]] { 1 } load(./cfg/app.toml) load(./secrets/key) ``` `sub check` says: ```text error[E0201]: `write(p, "oops")` is not allowed by `needs` --> fail_function_needs.sub:4:3 | 4 | write(p, "oops") | ^^^^^^^^^^^^^^^^ `needs` has no write permission = help: add `write [p]` to the `needs` of the function `load` (and to the script's `needs` if it is not there yet), or remove the call error[E0223]: `wide` asks for `read ./elsewhere`, which the script's `needs` does not allow --> fail_function_needs.sub:7:11 | 7 | fn wide() needs [read [./elsewhere]] { | ^^^^^^^^^^^^^^^^^^^^^^^^^^ bigger than the script's permissions = help: add `read [./elsewhere]` to the `needs` block at the top of the file, or remove the call error[E0221]: calling `load` needs `read ./secrets/key`, which is not allowed here --> fail_function_needs.sub:12:1 | 12 | load(./secrets/key) | ^^^^^^^^^^^^^^^^^^^ the function asks for more than this script has = help: add `read [./secrets/key]` to the `needs` block at the top of the file, or remove the call warning[W0603]: the failure of this call is ignored (IoError): if it fails, nothing says so --> fail_function_needs.sub:11:1 | 11 | load(./cfg/app.toml) | ^^^^^^^^^^^^^^^^^^^^ = help: add `?` to stop here when it fails, or `or { ... }` to say what to do warning[W0603]: the failure of this call is ignored (IoError): if it fails, nothing says so --> fail_function_needs.sub:12:1 | 12 | load(./secrets/key) | ^^^^^^^^^^^^^^^^^^^ = help: add `?` to stop here when it fails, or `or { ... }` to say what to do error: 3 errors found and 2 warnings ``` ## fail_match_incomplete.sub ```sub enum Light { Red, Yellow, Green } fn next(l: Light) { match (l) { Red => Green, Green => Yellow } } let delay = match (first([1, 2])) { Some(n) => n } ``` `sub check` says: ```text error[E0642]: this `match` does not cover: Yellow --> fail_match_incomplete.sub:4:3 | 4 | match (l) { | ^^^^^^^^^^^ not every case is covered = help: add those cases, or end with `_ => ...` to cover the rest error[E0642]: this `match` does not cover: None --> fail_match_incomplete.sub:10:13 | 10 | let delay = match (first([1, 2])) { | ^^^^^^^^^^^^^^^^^^^^^^^ not every case is covered = help: add those cases, or end with `_ => ...` to cover the rest error: 2 errors found ``` ## fail_needs_late.sub ```sub let greeting = "hello" needs [ read [./src] ] print(greeting) ``` `sub check` says: ```text error[E0114]: `needs` must be the first thing in the file --> fail_needs_late.sub:2:1 | 2 | needs [ read [./src] ] | ^^^^^ this block is too late = help: move the `needs [ ... ]` block to the top of the file, above all other code error: 1 error found ``` ## fail_no_needs.sub No needs block: this script can do nothing, so reading a file is refused. ```sub // No needs block: this script can do nothing, so reading a file is refused. let text = read(./notes.txt) print(text) ``` `sub check` says: ```text error[E0201]: `read(./notes.txt)` is not allowed by `needs` --> fail_no_needs.sub:2:12 | 2 | let text = read(./notes.txt) | ^^^^^^^^^^^^^^^^^ ./notes.txt is not covered by `needs` = help: this file has no `needs` block, so it can do nothing yet: add `needs [ read [./notes.txt] ]` at the top of the file, or remove the call error: 1 error found ``` ## fail_outside_path.sub ```sub needs [ read [./src] ] print(read(./src/main.ts)) print(read(../secrets.txt)) print(read(./src/../.env)) ``` `sub check` says: ```text error[E0201]: `read(../secrets.txt)` is not allowed by `needs` --> fail_outside_path.sub:4:7 | 4 | print(read(../secrets.txt)) | ^^^^^^^^^^^^^^^^^^^^ ../secrets.txt is not covered by `needs` = help: add `read [../secrets.txt]` to the `needs` block at the top of the file, or remove the call = note: that path is outside the folder the script runs in error[E0201]: `read(./src/../.env)` is not allowed by `needs` --> fail_outside_path.sub:5:7 | 5 | print(read(./src/../.env)) | ^^^^^^^^^^^^^^^^^^^ ./.env is not covered by `needs` = help: add `read [./.env]` to the `needs` block at the top of the file, or remove the call error: 2 errors found ``` ## fail_refined_literal.sub ```sub type Port = Int where 1..=65535 type Id = String matching "^ses_[0-9A-Z]\{4\}$" let ok: Port = 8080 let too_big: Port = 70000 let loose: Id = "session-1" let n = int(args[0]) or 0 let unchecked: Port = n ``` `sub check` says: ```text error[E0650]: 70000 is not a valid Port: it must be between 1 and 65535 --> fail_refined_literal.sub:5:21 | 5 | let too_big: Port = 70000 | ^^^^^ does not fit = help: `Port` is declared with a value between 1 and 65535 error[E0650]: "session-1" is not a valid Id: it must match "^ses_[0-9A-Z]{4}$" --> fail_refined_literal.sub:6:17 | 6 | let loose: Id = "session-1" | ^^^^^^^^^^^ does not fit = help: `Id` is declared with text matching "^ses_[0-9A-Z]{4}$" error[E0610]: this value: expected Port, found Int --> fail_refined_literal.sub:9:23 | 9 | let unchecked: Port = n | ^ this is Int = help: convert it with Port(x): it checks the value when the script runs and gives a failure if it does not fit error: 3 errors found ``` ## fail_secret_leak.sub ```sub needs [ env ["API_TOKEN"] ] let token = env("API_TOKEN") or "" print("using token", token) let header = "Bearer {token}" ``` `sub check` says: ```text error[E0302]: a secret cannot be passed to `print` --> fail_secret_leak.sub:4:22 | 4 | print("using token", token) | ^^^^^ this is a secret = help: a secret can only be compared with secret_eq(a, b) or passed to your own function that takes a `Secret` error[E0301]: a secret cannot be put into a string --> fail_secret_leak.sub:5:23 | 5 | let header = "Bearer {token}" | ^^^^^ this is a secret = help: secrets can only be passed to functions that take a `Secret`, such as secret_eq(a, b) error: 2 errors found ``` ## fail_shell_pipe.sub ```sub needs [ run ["ls", "wc"] ] let n = run("ls | wc -l") ``` `sub check` says: ```text error[E0202]: `run` does not use a shell, so `|` has no meaning here --> fail_shell_pipe.sub:3:13 | 3 | let n = run("ls | wc -l") | ^^^^^^^^^^^^ `|` would be passed to the program as plain text = help: run each program with its own `run(...)` and pass `.output` on; to give a program several arguments use a list: run(["git", "commit", "-m", message]) warning[W0402]: `ls` in `run` is never used --> fail_shell_pipe.sub:1:14 | 1 | needs [ run ["ls", "wc"] ] | ^^^^ unused permission = help: remove it to ask for less warning[W0402]: `wc` in `run` is never used --> fail_shell_pipe.sub:1:20 | 1 | needs [ run ["ls", "wc"] ] | ^^^^ unused permission = help: remove it to ask for less error: 1 error found and 2 warnings ``` ## fail_syntax.sub ```sub needs [ read [./src] ] let text = read(./src/main.ts print(text) ``` `sub check` says: ```text error[E0110]: expected `,` or `)`, found the name `print` --> fail_syntax.sub:4:1 | 4 | print(text) | ^^^^^ expected `,` or `)` here = help: separate items with commas, and close the list with `)` (opened on line 3) error: 1 error found ``` ## fail_type_mismatch.sub Types are inferred; these mistakes are found before the script runs. ```sub // Types are inferred; these mistakes are found before the script runs. needs [ env ["API_TOKEN"] ] let count: Int = "three" let label = "items: " + count let total = len([1, 2]) + "2" let token = env("API_TOKEN") or { return 1 } let shown: String = token ``` `sub check` says: ```text error[E0610]: this value: expected Int, found String --> fail_type_mismatch.sub:4:18 | 4 | let count: Int = "three" | ^^^^^^^ this is String = help: turn the text into a number with int(x), which gives a failure if it is not a number: `int(x)?` or `int(x) or 0` error[E0630]: `+` needs two numbers, two lists, or text on the left: Int and String --> fail_type_mismatch.sub:6:13 | 6 | let total = len([1, 2]) + "2" | ^^^^^^^^^^^^^^^^^ Int and String = help: turn a number into text with str(x) to add it to text error[E0610]: this value: expected String, found Secret --> fail_type_mismatch.sub:8:21 | 8 | let shown: String = token | ^^^^^ this is Secret = help: a secret is not a plain value: it can only be compared with secret_eq(a, b) or passed on to something that takes a Secret error: 3 errors found ``` ## fail_typo.sub ```sub needs [ read [./notes] ] let count = len(lines(read(./notes/today.md) or "")) print("lines:", cuont) rad(./notes/today.md) ``` `sub check` says: ```text error[E0310]: `cuont` is not defined --> fail_typo.sub:4:17 | 4 | print("lines:", cuont) | ^^^^^ unknown name = help: did you mean `count`? error[E0311]: there is no function called `rad` --> fail_typo.sub:5:1 | 5 | rad(./notes/today.md) | ^^^ unknown function = help: did you mean `read`? error: 2 errors found ``` ## fix_tests.sub Run the tests; when they fail, patch the failing file and commit the patch. ```sub // Run the tests; when they fail, patch the failing file and commit the patch. needs [ read [./src], write [./src], run ["npm test", "git add ./src", "git commit -m *"], ] fn fix(text, output) { text + "// TODO: the tests said: " + trim(output) + "\n" } let t = run("npm test") if (t.failed) { let path = ./src/index.ts let text = read(path)? write(path, fix(text, t.output))? run("git add ./src") run(["git", "commit", "-m", "fix failing test"]) } else { print("all tests pass") } ``` `sub explain` says: ```text fix_tests.sub It can: ├─ can read files │ ./src and everything below it ├─ can write files │ ./src and everything below it ├─ can run programs │ git add ./src and any arguments after it │ git commit -m * exactly these arguments (a * is any one word) │ npm test and any arguments after it └─ cannot use the network, read environment variables, read the clock, or use random numbers The calls it makes: line 12 run("npm test") allowed by `npm test` line 15 read(path) allowed by `./src` line 16 write(path, fix(text, t.output)) allowed by `./src` line 17 run("git add ./src") allowed by `git add ./src` line 18 run(["git", "commit", "-m", "fix failing test"]) allowed by `git commit -m *` ``` ## github_status.sub Ask the GitHub API how many requests are left. Needs a token in GITHUB_TOKEN. ```sub // Ask the GitHub API how many requests are left. Needs a token in GITHUB_TOKEN. needs [ net ["api.github.com:443"], env ["GITHUB_TOKEN"], ] let token = env("GITHUB_TOKEN") or { print("set GITHUB_TOKEN first") return 1 } match (fetch("https://api.github.com/rate_limit")) { Ok(body) => print("rate limit says:", len(body), "bytes"), Err(e) => print("could not reach GitHub:", e) } ``` `sub explain` says: ```text github_status.sub It can: ├─ can reach the network │ api.github.com port 443 only ├─ can read environment variables │ GITHUB_TOKEN └─ cannot read files, write files, run programs, read the clock, or use random numbers The calls it makes: line 7 env("GITHUB_TOKEN") allowed by `GITHUB_TOKEN` line 11 fetch("https://api.github.com/rate_limit") allowed by `api.github.com:443` ``` ## release_notes.sub Turn the git log into release notes. (Make ./out first.) ```sub // Turn the git log into release notes. (Make ./out first.) needs [ run ["git log --oneline"], write [./out], ] let log = run("git log --oneline") if (log.failed) { print("git log failed:", log.error) return 1 } let mut notes = "# Release notes\n\n" for (line in lines(log.output)) { notes = notes + "- " + line + "\n" } write(./out/NOTES.md, notes)? print("wrote ./out/NOTES.md with", len(lines(log.output)), "entries") ``` `sub explain` says: ```text release_notes.sub It can: ├─ can write files │ ./out and everything below it ├─ can run programs │ git log --oneline and any arguments after it └─ cannot read files, use the network, read environment variables, read the clock, or use random numbers The calls it makes: line 7 run("git log --oneline") allowed by `git log --oneline` line 16 write(./out/NOTES.md, notes) allowed by `./out` ``` ## secrets.sub A secret can be compared and passed on to something that takes a Secret, never printed. ```sub // A secret can be compared and passed on to something that takes a Secret, never printed. needs [ env ["API_TOKEN"] ] let token = env("API_TOKEN") or { print("API_TOKEN is not set") return 1 } if (secret_eq(token, "")) { print("the token is empty") } else { print("a token is set") } ``` `sub explain` says: ```text secrets.sub It can: ├─ can read environment variables │ API_TOKEN └─ cannot read files, write files, run programs, use the network, read the clock, or use random numbers The calls it makes: line 4 env("API_TOKEN") allowed by `API_TOKEN` ``` ## todo_report.sub Count the TODO lines in every file of ./src. The helper may only read the file it is given. ```sub // Count the TODO lines in every file of ./src. The helper may only read the file it is given. needs [ read [./src] ] fn todos_in(path) needs [read [path]] { let mut n = 0 for (line in lines(read(path) or "")) { if (contains(line, "TODO")) { n = n + 1 } } n } let mut total = 0 for (name in list_dir(./src) or []) { let file = join_path(./src, name) let n = todos_in(file) print(name, n) total = total + n } print("total TODO:", total) ``` `sub explain` says: ```text todo_report.sub It can: ├─ can read files │ ./src and everything below it └─ cannot write files, run programs, use the network, read environment variables, read the clock, or use random numbers The calls it makes: line 6 read(path) allowed by `parameter path` line 15 list_dir(./src) allowed by `./src` ``` ## types_tour.sub A tour of the types: enums and match, failure families, refined types, options and lambdas. ```sub // A tour of the types: enums and match, failure families, refined types, options and lambdas. type Port = Int where 1..=65535 type Name = String where len 1..=8 enum Shape { Circle(Int), Rect(Int, Int), Empty } error ConfigError error Missing <: ConfigError error Malformed(line: Int) <: ConfigError fn area(s: Shape) -> Int { match (s) { Circle(r) => 3 * r * r, Rect(w, h) => w * h, Empty => 0 } } fn parse_port(text) -> Result { if (text == "") { return Missing("no port given") } let n = int(text) or { return Malformed(1) } Port(n) or { return Malformed(2) } } fn describe(text) { match (parse_port(text)) { Ok(p) => "port {p}", Err(Missing) => "missing", Err(ConfigError) => "bad config" } } defer print("done") let shapes = [Circle(2), Rect(3, 4), Empty] print(map(shapes, area)) print(fold(shapes, 0, fn(total, s) { total + area(s) })) let default_port: Port = 8080 print(default_port + 1) print(describe("443"), describe(""), describe("x"), describe("0")) let name: Name = "sub" let record = { name: name, port: default_port, ratio: 7 / 2.0 } let moved = { ..record, port: 9090 } print(record.port, moved.port, moved.ratio) let found = find([1, 5, 8], fn(n) { n > 4 }) print(unwrap_or(found, 0), unwrap_or(find([1], fn(n) { n > 4 }), -1)) print(match (first([10, 20])) { Some(x) => x + 1, None => 0 }) ``` `sub explain` says: ```text types_tour.sub It can do nothing: the file has no `needs` block. ``` ## warn_unhandled_failure.sub Not errors: failures that would stop the script, or that nobody looks at. ```sub // Not errors: failures that would stop the script, or that nobody looks at. needs [ read [./notes], write [./notes] ] let text = read(./notes/today.md) print(len(text)) write(./notes/copy.md, "x") let safe = read(./notes/today.md) or "" print(len(safe)) ``` `sub explain` says: ```text warn_unhandled_failure.sub It can: ├─ can read files │ ./notes and everything below it ├─ can write files │ ./notes and everything below it └─ cannot run programs, use the network, read environment variables, read the clock, or use random numbers The calls it makes: line 4 read(./notes/today.md) allowed by `./notes` line 6 write(./notes/copy.md, "x") allowed by `./notes` line 7 read(./notes/today.md) allowed by `./notes` ``` --- # Roadmap ## Works today - **Front end:** lexer, parser, precise diagnostics with a suggested fix. - **Checker:** `needs` against every call, the capability subset rule, secrets, a real type checker (inference, generics, enums with exhaustive `match`, failure families, refined types). - **Backends:** WebAssembly on Wasmtime with host-checked effects, and a WASI module for any runtime. - **Language:** floats, enums, records with update, lambdas, `defer`, tasks, modules with `use`, a standard library, `test` blocks. - **Tooling:** `sub check`, `explain`, `manifest`, `build`, `run`, `test`, `fmt`, `init`, a language server. - **Memory:** region collection in the Wasm backend. - **Centcom:** `sub manifest --centcom` and an integration design. ## Next | Phase | What | | --- | --- | | Soon | A fuller self-hosted compiler (only the lexer is written in Subtype), `while` and `break`, mutable lists, a `Map` literal | | Soon | Library namespaces (`use ... as`), private names, a package manager | | Soon | Wasmtime for `sub test`, a policy file for unattended runs | | Later | An LLVM or native backend, on purpose after the Wasm path | | Later | The whole compiler written in Subtype | ## Open questions - How an unattended agent gets its permissions approved, beyond `--yes` and the `.sub-allow` list. - How scripts call tools like git safely, beyond word-by-word `run` patterns. - The name and command: language names, trademarks, package names and the `sub` command should be checked before a public release. --- # Brand The mark is a **gate within a gate**: a smaller door inside a bigger door. Less power inside more. | Name | Hex | Use | | --- | --- | --- | | Charcoal | `#2b2724` | outer door on light, text on light | | Ink | `#1f1d1b` | dark backgrounds | | Cream | `#f4efe6` | outer door on dark | | Clay | `#c96442` | inner door, the accent | | Violet | `#6b5bd6` | middle door | Tagline: **Less than you asked for, on purpose.** Second line: *Safe scripts and tools for AI agents.* Files live in `brand/gate/` in the repository; the guide is `docs/brand.md`. --- # For AI agents This section is for agents (and for the people who configure them). Everything here is plain text an agent can fetch. ## Fetch the language | What | URL | Format | | --- | --- | --- | | Index of all pages (the llms.txt convention) | [`/llms.txt`](/llms.txt) | text, links to each page as Markdown | | The whole documentation in one file | [`/llms-full.txt`](/llms-full.txt) | one Markdown file | | Any single page as Markdown | `/md/.md`, for example [`/md/language/needs.md`](/md/language/needs.md) | Markdown | | Machine-readable language spec: keywords, operators, built-ins with the permission each needs, error codes with fixes, grammar | [`/spec.json`](/spec.json) | JSON (`subtype-spec/1`) | | Compact reference, one screen per topic | [`/spec.md`](/spec.md) | Markdown | | The same data from the compiler itself | `sub spec --json`, `sub spec --markdown`, `sub spec --grammar` | JSON / text | A good first request is `/llms-full.txt`: it fits in a model's context and has every rule, every built-in and a gallery of 20 checked scripts. ## When to use Subtype Write a Subtype script instead of shell, Python or Node when a task: - reads, writes or deletes files, runs programs, calls a network host or reads environment variables on a person's machine, and - a person will approve or review it (or a policy will). The person approves one plain-words permission list instead of reading code. Subtype is a poor fit for long-running services or heavy computation. ## The loop 1. **Declare first.** Begin the file with `needs [ ... ]` listing only what the script needs, as narrowly as possible (`read [./src]`, not `read [.]`; `run ["git status"]`, not `run ["git"]`). 2. **Check.** Run `sub check file.sub`. Every error names the call, the line and the exact change. Apply it or remove the call. Do not widen `needs` just to silence an error: ask whether the script really needs the permission. 3. **Explain.** Run `sub explain file.sub` and show the person the result, or read it yourself to confirm the script cannot do more than the task needs. 4. **Run.** `sub run file.sub`. The person sees the permission list and approves. `--yes` only when a person has already approved this exact list (the compiler can compare the manifest hash). 5. **On exit code 3 or 4** (a permission was denied, or not approved): do not retry with broader permissions. Report what was denied and why the script needed it. ## A prompt snippet Put this in a system prompt, `CLAUDE.md` or `AGENTS.md`: ```text For scripts that touch files, run commands, use the network or read environment variables, write Subtype (.sub) instead of shell, Python or Node. Docs for agents: https://subtypelang.com/llms-full.txt (spec: https://subtypelang.com/spec.json) Workflow: write the file starting with a narrow `needs [ ... ]` header, run `sub check`, fix every error with the fix the compiler suggests (never widen `needs` just to silence an error), run `sub explain` and show the person the result, then `sub run`. If a permission is denied, report it; do not retry broader. ``` ## Rules of the language an agent must not forget - The file starts with `needs [ ... ]`. A missing key means none. `net []` means no network. - `write` does not include `read`. `run` has no shell: no `|`, `;`, `&`, `>`, `<`, backticks or `$(`. Use a list of words or one `run` per program. - Failure is a value: `?` passes it up, `or { }` handles it. There are no exceptions. - A `Secret` (anything from `env(...)`) cannot reach `print`, `log` or a string. Compare with `secret_eq`. - Errors are declared names (`error Missing <: ConfigError`), not `Err(...)`. - A function may declare a smaller `needs` than its caller holds, never a bigger one. ## The Claude Code skill A skill that tells Claude Code when to use Subtype and how, with verified examples, recipes for translating shell and Python, the compiler's own reference and helper scripts. - Download: [`/subtype-skill.zip`](/subtype-skill.zip) - Install: unzip into `~/.claude/skills/` (it makes `~/.claude/skills/subtype/`). For one project only, use `/.claude/skills/`. - It contains `SKILL.md` (the rules and the loop), `examples/` (scripts that pass `sub check`), `references/` (a cheat sheet, recipes and the compiler's spec), `scripts/ensure-sub.sh` (finds or builds `sub`), `scripts/review.sh` (check, explain and the permission hash to show a person), and an optional reminder hook. ## The manifest `sub manifest file.sub` prints the permission set and every call as JSON, with a SHA-256 of the permission set. Tools compare the hash with what a person already approved. `sub manifest --centcom` shapes it for Centcom's approval prompt. ```json { "format": "subtype-manifest/1", "needs": { "read": ["./src"], "write": ["./src"], "run": ["npm test"], "net": [] }, "hash": "sha256:..." } ``` --- # Subtype language reference (spec subtype-spec/1, compiler 0.0.1) Subtype scripts declare what they may touch in a `needs [ ... ]` header and can do nothing else. Files end in `.sub`; `sub run`, `sub check`, `sub explain`, `sub fmt`, `sub test`. ## Permissions (`needs`) - `read`: read files and list folders. Items: paths: ./dir (everything below), ./dir/*.ts (files directly in it). Example: `needs [ read [./src] ]` - `write`: create and change files. Items: paths, as for read; write does not imply read. Example: `needs [ write [./out] ]` - `run`: run programs; there is no shell, so | ; & > < and backticks are errors. Items: quoted commands: "git status" (and any arguments), "git commit -m *" (exactly these arguments, * is one word). Example: `needs [ run ["git status"] ]` - `net`: reach hosts over http and https. Items: quoted hosts: "api.github.com:443", "*.example.org", "*". Example: `needs [ net ["api.github.com:443"] ]` - `env`: read environment variables (the values are secrets). Items: quoted names: "HOME", "AWS_*". Example: `needs [ env ["HOME"] ]` - `clock`: read the time and sleep. Items: bare word (no list). Example: `needs [ clock ]` - `random`: random numbers. Items: bare word (no list). Example: `needs [ random ]` Rules: - A script can do only what its `needs` allows; anything else is an error when it is checked, and again when it runs. - A smaller permission is a subtype of a bigger one: `read [./src/a]` fits inside `read [./src]`, `run ["git status"]` inside `run ["git"]`, one host inside `*.example.org`. - A function with its own `needs` runs inside the smaller set, and may not ask for more than the script has. - A capability value is never bigger than `needs`; `demote(a, b)` only hands over less. - A library (`use`) has no `needs` of its own: its functions say what they need, and the script that calls them must allow it. - A `Secret` cannot reach text, `print`, `log`, or a function that does not take a `Secret`. - A path is compared after symbolic links are resolved, so a link cannot lead out of a permitted folder. ## Keywords - `needs`: the permission header: everything the script may touch, first thing in the file - `let`: names a value (`let mut` makes the name changeable); also starts a pattern in `if (let ...)` - `mut`: a name that can be assigned again - `fn`: a function (a declaration at the top level, or a value inside an expression) - `if`: a condition in parentheses; also a guard after a `match` pattern - `else`: the other branch of an `if` - `for`: goes through a list, map, set or text; the item may be a pattern - `in`: between the item and the collection of a `for` - `while`: repeats while a condition holds - `break`: leaves the innermost loop - `continue`: goes on with the next round of the innermost loop - `match`: takes a value apart with patterns; the cases must cover every value - `or`: what to do when the left side failed: a value or a block (`error` is the failure inside) - `return`: leaves the function (or the script, with an exit code) with a value - `true`: the yes value - `false`: the no value - `type` (contextual): a new name for a type, optionally refined - `enum` (contextual): a type with a fixed set of variants - `error` (contextual): a kind of failure, optionally inside a family; also the failure inside an `or { }` block - `use` (contextual): brings in another file of declarations - `defer` (contextual): runs an expression when the block ends, however it ends - `test` (contextual): a test block, run by `sub test` - `where` (contextual): the condition of a refined type - `matching` (contextual): a refined text type that must match a pattern ## Operators (lowest first) - `or` (handle a failure): the left side, or the right side (a value or a block) when the left failed - `??` (coalesce): the left side when it is there, else the right (for `Option` and failures) - `||` (or): yes when either side is yes (the right is not evaluated if the left is) - `&&` (and): yes when both sides are yes (the right is not evaluated if the left is no) - `== !=` (equal, not equal): structural equality (records, lists, tuples, sets; 1 equals 1.0); a secret equals nothing - `< > <= >=` (compare): orders numbers, and text by characters - `.. ..=` (range): the list of whole numbers from a up to (or through) b - `+ -` (add, subtract): arithmetic; `+` also joins text and lists - `* / %` (multiply, divide, remainder): arithmetic; whole numbers overflow into an error, `/` by zero is an error - `! -` (not, negate): turns yes into no; flips the sign of a number - `? . ?. [] ()` (postfix): `?` passes a failure up, `.f` a field, `?.f` an optional field, `[i]` an item, `(a)` a call; no space before `[` or `(` - `= += -= *= /= %=` (assign): changes a `mut` name, a field (`r.n = 1`) or an item (`xs[0] = 1`) ## Literals - Int: `123 1_000 0xFF 0b1010 0o17`. a 64-bit whole number; a result that does not fit is an error, never a wrap-around - Float: `1.5 2e9 1.5E-3`. a 64-bit fraction; a whole number is accepted where a Float is wanted - Bool: `true false`. yes or no - String: `"text {expr} \n"`. text with escapes (\n \t \r \0 \" \\ \{ \} \xNN \u{N}) and `{expression}` parts; `{x:>8}` formats - String (raw): `r"no \escapes {here}" r#"quotes " ok"#`. text exactly as written - String (triple): `"""several lines"""`. text that spans lines; a line break right after the opening quotes is dropped - Path: `./src/main.ts ../up ~/notes /etc/hosts ./out/{name}.txt`. a file path written without quotes; `{expr}` parts allowed - List: `[1, 2, 3]`. an ordered collection of one kind of value - Record: `{ name: "ann", age: 3 } { ..r, age: 4 }`. named fields of different types; `{ ..r, f: v }` is a copy with changes - Tuple: `(1, "a")`. a fixed number of values of different types; `t.0` is the first - Map: `#{ "a": 1, "b": 2 }`. text keys to one kind of value; `m["a"]` looks one up - Set: `#[1, 2, 3]`. members once each, in the order added - Function: `fn(a, b) { a + b }`. a function value that captures the names it uses - Capability: `read[./src] run["git status"]`. a permission as a value, never bigger than `needs`; `demote` makes one smaller ## Types - `Int`: whole number (64-bit) - `Float`: fraction; an Int is accepted where a Float is wanted - `Bool`: true or false - `String`: text; a Path is accepted where a String is wanted - `Path`: a file path - `Unit`: no value - `Any`: any value (the checker does not look inside) - `Never`: a function that never returns - `List`: list of T - `Map`: text keys to T - `Set`: set of T - `Option`: Some(x) or None - `Result`: a T, or a failure of family E (`Result` means any failure) - `Secret`: a T that cannot be printed, put in text or compared with ==; not a subtype of T nor T of it - `Task`: work started with `spawn` - `(A, B)`: tuple - `Fn`: a function from A to B (the last type is the result) Failure families (a failure kind fits where its family is wanted): Failure, IoError <: Failure, NotFound <: IoError, OsDenied <: IoError, Io <: IoError, ParseError <: Failure, Parse <: ParseError, OutOfRange <: Failure, NetworkError <: Failure, NetUnavailable <: NetworkError, TaskFailed <: Failure, Cancelled <: TaskFailed, Refinement <: Failure, Assertion <: Failure. ## Built-in functions - `print(Any...) -> Unit`: writes the values, separated by spaces, and a new line, to standard output - `log(Any...) -> Unit`: the same, to standard error - `read(Str) -> Result` [needs read]: the text of a file - `exists(Str) -> Bool` [needs read]: whether a file or folder exists - `list_dir(Str) -> Result, IoError>` [needs read]: the names in a folder, sorted - `write(Str, Str) -> Result` [needs write]: writes text to a file - `run(Any) -> RunResult` [needs run]: runs a program (a command string or a list of words, no shell); a program that cannot start is a result with `failed` set and code 127 - `fetch(Str) -> Result` [needs net]: an HTTP GET; the body as text - `env(Str) -> Result, NotFound>` [needs env]: an environment variable, as a secret - `now() -> Int` [needs clock]: milliseconds since 1970 - `sleep(Int) -> Unit` [needs clock]: waits this many milliseconds - `random() -> Int` [needs random]: a random non-negative integer - `read_in(CapRead, Str) -> Result` [needs read]: reads a file inside a read capability - `write_in(CapWrite, Str, Str) -> Result` [needs write]: writes a file inside a write capability - `demote(T, T) -> T`: hands over a smaller capability - `args() -> List`: the arguments the script was run with - `len(Any) -> Int`: the length of a string, list, map or record - `lines(Str) -> List`: splits text into lines - `split(Str, Str) -> List`: splits text at a separator - `trim(Str) -> Str`: removes spaces at both ends - `join(List, Str) -> Str`: joins items with a separator - `contains(Any, Any) -> Bool`: whether a string contains text, or a list contains an item - `starts_with(Str, Str) -> Bool`: whether text begins with the given text - `ends_with(Str, Str) -> Bool`: whether text ends with the given text - `replace(Str, Str, Str) -> Str`: replaces every occurrence - `upper(Str) -> Str`: the text in capital letters - `lower(Str) -> Str`: the text in small letters - `slice(T, Int, Int) -> T`: the characters of text, or the items of a list, from the first position up to, not including, the second (negative positions count from the end); `xs[1..3]` is the same - `slice_from(T, Int) -> T`: everything from a position on; `xs[2..]` is the same - `slice_to(T, Int) -> T`: everything before a position; `xs[..3]` is the same - `index_of(Str, Str) -> Option`: where text first occurs (in characters) - `repeat(Str, Int) -> Str`: the text repeated n times - `chars(Str) -> List`: the characters, one string each - `str(Any) -> Str`: any value as text (a secret is refused) - `secret_eq(Any, Any) -> Bool`: compares a secret with a secret or text - `join_path(Str, Str) -> Path`: appends a part to a path - `path_parent(Str) -> Str`: the folder a path is in - `path_name(Str) -> Str`: the last part of a path - `path_stem(Str) -> Str`: the last part without its extension - `path_ext(Str) -> Str`: the extension without the dot - `int(Any) -> Result`: a number from text, or a float cut to a whole number - `float(Any) -> Result`: a number from text or an integer - `abs(T) -> T`: the size of a number without its sign (an error for the smallest whole number) - `min(T, T) -> T`: the smaller of two numbers (or two texts) - `max(T, T) -> T`: the larger of two numbers (or two texts) - `floor(Float) -> Int`: the whole number at or below a fraction - `ceil(Float) -> Int`: the whole number at or above a fraction - `round(Float) -> Int`: the nearest whole number (halves go away from zero) - `sqrt(Float) -> Float`: the square root - `pow(Float, Float) -> Float`: a number raised to a power - `push(List, T) -> List`: a new list with the item added at the end - `first(List) -> Option`: the first item, if the list has one - `last(List) -> Option`: the last item, if the list has one - `get(List, Int) -> Option`: the item at a position, if there is one - `unwrap_or(Option, T) -> T`: the value inside, or the fallback when there is none - `is_some(Option) -> Bool`: whether an Option holds a value - `is_none(Option) -> Bool`: whether an Option is empty - `take(List, Int) -> List`: the first n items - `drop(List, Int) -> List`: all but the first n items - `reverse(List) -> List`: the list backwards - `sort(List) -> List`: numbers or text, smallest first - `sort_by(List, Fn(T) -> Any) -> List`: sorted by a key - `range(Int, Int) -> List`: the numbers from the first up to, not including, the second - `map(List, Fn(T) -> U) -> List`: a list of what a function gives for each item - `filter(List, Fn(T) -> Bool) -> List`: the items a function says yes to - `fold(List, U, Fn(U, T) -> U) -> U`: combines the items one by one, starting from a value - `any(List, Fn(T) -> Bool) -> Bool`: whether a function says yes to at least one item - `all(List, Fn(T) -> Bool) -> Bool`: whether a function says yes to every item - `find(List, Fn(T) -> Bool) -> Option`: the first item a function says yes to, if there is one - `map_new() -> Map`: an empty map - `map_set(Map, Str, T) -> Map`: a new map with the key set - `map_get(Map, Str) -> Option`: the value for a key, if there is one - `map_has(Map, Str) -> Bool`: whether a map has a key - `map_remove(Map, Str) -> Map`: a map without a key - `map_keys(Map) -> List`: the keys, sorted - `map_values(Map) -> List`: the values, in key order - `json_parse(Str) -> Result`: text to lists, maps, numbers, text, yes/no values - `json_stringify(Any) -> Str`: a value as JSON text - `matches(Str, Str) -> Bool`: whether text matches a pattern (letters, . * + ? [] [^] ^ $ | () {n,m} \d \w \s) - `regex_find(Str, Str) -> Option`: the first piece of text that matches a pattern - `regex_replace(Str, Str, Str) -> Str`: replaces every match of a pattern - `format_time(Int) -> Str`: milliseconds since 1970 as 2026-10-12T09:30:00Z - `assert(Bool) -> Unit`: stops with a failure when the value is false - `assert_eq(T, T) -> Unit`: stops with a failure when the values differ - `tuple(Any...) -> Any`: a tuple of the values: `(1, "a")` is `tuple(1, "a")`; `t.0` is its first part - `map_from(Any...) -> Map`: a map from alternating keys and values: `#{ "a": 1 }` is `map_from("a", 1)` - `map_entries(Map) -> List>`: the (key, value) pairs of a map - `set_of(T...) -> Set`: a set of the values, each once: `#[1, 2, 3]` is `set_of(1, 2, 3)` - `set_from_list(List) -> Set`: the members of a list, each once - `set_add(Set, T) -> Set`: a set with one more member - `set_remove(Set, T) -> Set`: a set without a member - `set_has(Set, T) -> Bool`: whether a set has a member - `set_union(Set, Set) -> Set`: the members of either set - `set_intersect(Set, Set) -> Set`: the members of both sets - `set_diff(Set, Set) -> Set`: the members of the first set that the second does not have - `set_to_list(Set) -> List`: the members as a list, in the order they were added - `set_at(T, Any, Any) -> T`: a copy of a list, map, record or tuple with one item replaced: `xs[i] = v` is `xs = set_at(xs, i, v)` - `enumerate(List) -> List>`: the items with their positions: `for (i, x in enumerate(xs))` - `zip(List, List) -> List>`: pairs of items from two lists, as many as the shorter list has - `insert(List, Int, T) -> List`: a list with an item put in at a position - `remove_at(List, Int) -> List`: a list without the item at a position - `pop_last(List) -> List`: a list without its last item - `clear(T) -> T`: an empty list, map, set or text - `extend(T, T) -> T`: two lists joined, or two maps merged (the second wins), or two sets united - `truncate(List, Int) -> List`: the first n items - `fmt_spec(Any, Str) -> Str`: a value formatted with a spec such as `>8` or `.2f`: `"{x:.2f}"` is `fmt_spec(x, ".2f")` - `panic(Str) -> Never`: stops the script with a message - `spawn(Fn() -> T) -> Task`: starts a task; it is cancelled when the block that started it ends, unless awaited - `await(Task) -> Result`: waits for a task - `cancel(Task) -> Unit`: asks a task to stop ## Commands - `sub check [--strict]`: find problems; --strict also fails on warnings - `sub explain [--json]`: what the script can do, in plain words (or as data) - `sub manifest [--centcom]`: the permission set and calls, as JSON (--centcom: an approval card and agent allow rules for Centcom) - `sub build [-o out.wasm] [--wasi]`: compile to a WebAssembly module (--wasi: a WASI command module) - `sub run [--yes] [--root DIR] [--timeout SECS] [--max-output BYTES] [--fetch-max BYTES] [--interp] [-- args...]`: compile (if needed), ask to approve the permissions, run - `sub test [--yes] [--root DIR]`: run the test blocks of a script - `sub fmt ... [--check] [--stdout]`: one layout for every script (idempotent) - `sub tokens `: the tokens, one per line (the lexer in Rust; examples/selfhost has the one in Subtype) - `sub init [dir]`: start a project: main.sub, a library and a test - `sub spec [--json|--markdown|--grammar]`: the language specification from the compiler's own tables Exit codes: 0 done; 1 failed, or did not check; 2 usage error; 3 a permission was denied while running; 4 the permissions were not approved. ## Diagnostics - E0101 a string is never closed: A `"` starts a string and needs another `"` to end it (a raw string ends with its own quote and hashes). - E0102 a character that is not part of the language: Single `&` and `|` do not exist (use `&&` and `||`); there are no pipes, run each program with `run`. `|` only separates alternatives in a pattern. - E0103 a number cannot be read: Whole numbers go up to 9223372036854775807; a hex, binary or octal number needs digits; a float must be finite. - E0104 an escape in a string is not valid: Escapes are \n \t \r \0 \" \\ \{ \} \xNN (up to 7F) and \u{N} (1 to 6 hex digits). - E0105 a `{` in a string is never closed, or is empty: `{expression}` puts a value in a string; write `\{` for a literal brace. - E0106 strings inside `{...}` are nested too deeply: The limit is 16 levels; compute the inner value first with `let`. - E0110 the parser expected something else: The message says what was expected and what was found. - E0111 a bracket is never closed: Close the `(`, `[` or `{` opened earlier. - E0112 a statement does not end where it should: Put each statement on its own line. - E0113 semicolons do not exist: A line break ends a statement. - E0114 `needs` must be the first thing in the file: Move the `needs [ ... ]` block above all other code. - E0115 more than one expression inside `{...}` of a string: Only one name or expression fits in `{...}`. - E0116 a space between a function name and `(`: Write the call with the bracket right after the name. - E0117 a field name cannot contain `{...}`: Field names are fixed text. - E0118 a pattern, file path or test name cannot contain `{...}`: These are fixed text. - E0121 a permission must be written out in full: Permissions are fixed text, so a reader can approve them without running the script. - E0122 a name where a permission item is needed: Names are only allowed in a function's own `needs`, for its parameters. - E0123 not a permission: The permissions are read, write, run, net, env, clock and random. - E0124 a permission that takes no list: `clock` and `random` are bare words. - E0130 a declaration in a place where it is not allowed: Functions, types, enums, error kinds, `use` and tests go at the top level. - E0131 the condition of `if` or `while` is not in parentheses: Write `if (condition) { ... }`. - E0140 a range with no ends: Write at least one end: `1..`, `..9`, `1..=9`. - E0141 a range that is empty: The first end is after the second. - E0142 a variant name must start with a capital letter: Capital letters tell variants from names. - E0150 `break` or `continue` outside a loop: They only make sense inside `for` or `while`; use `return` to leave a function. - E0151 this cannot be assigned to: Assign to a name, a field (`r.n = 1`) or an item (`xs[0] = 1`). - E0152 `..` must be last in a list pattern: Write `[first, second, ..rest]`. - E0170 not a library path: A path in `use` starts with ./ or ../ and ends in .sub. - E0171 a library cannot be read: Check the path, relative to the file with the `use`. - E0172 too many files: At most 64 files are used by one script. - E0173 a library has a `needs` block: Put `needs` on the functions that need something; the script that calls them must allow it. - E0174 a library holds a statement that runs: A library holds declarations only. - E0199 nested too deeply: The source nests deeper than the compiler reads. - E0201 a call is not allowed by `needs`: Add the permission to `needs`, or remove the call. - E0202 a shell operator in a command: `run` has no shell: `|`, `;`, `&`, `>`, `<`, backticks and `$(` have no meaning. Give the program and its arguments, or run each program on its own. - E0203 a command that is empty or cannot be read: A command is a program and its arguments. - E0204 a URL that cannot be used: Only http and https URLs with a host are fetched. - E0210 a permission item that is not valid: The message says what is wrong with the path, command or host. - E0211 the wrong kind of item for a permission: `run`, `net` and `env` take quoted text; `read` and `write` take paths. - E0220 a capability value is bigger than `needs` allows: A capability can only be made from what the script was given. - E0221 a call needs more than is allowed here: The function called asks for a permission that the caller's set does not include. - E0222 `demote` must hand over less: The second capability must be inside the first. - E0223 a function asks for more than the script has: A function's `needs` must fit inside the script's. - E0224 a capability value holds exactly one item: Make one capability per item. - E0225 a name in `needs` that is not a parameter: The names in a function's `needs` must be its parameters. - E0301 a secret in a string: Secrets cannot be put into text. - E0302 a secret passed to a function that prints: `print`, `log` and the like never take a secret. - E0303 a secret passed to a parameter that is not a Secret: Declare the parameter `Secret`. - E0310 a name is not defined: Declare it with `let`; the message suggests close names. - E0311 there is no such function: The message suggests close names. - E0312 the wrong number of arguments: The message says how many are wanted. - E0313 a built-in cannot be redefined, or a function is defined twice: Choose another name. - E0314 a name declared with `let` cannot be changed: Declare it with `let mut`. - E0501 not supported by the WebAssembly backend: The interpreter accepts it; the compiled module does not yet. - E0601 not a type: The message suggests close names. - E0602 a type name is used twice: Choose another name. - E0603 not a failure family: A failure kind can be inside a family declared with `error`. - E0604 a variant name is used twice: Variant names are unique in a program. - E0610 a value of the wrong type: The message says what was expected and what was found, and how to convert. - E0611 two branches give different types: Make `if`, `match`, `or` and `??` give one kind of value. - E0612 a function returns on some paths only: Return a value on every path, or on none. - E0613 no such field: The message lists the fields. - E0620 `?` on a value that cannot fail: Remove the `?`. - E0621 `?` in a function declared not to fail: Declare the failure in the return type, or handle it with `or`. - E0630 an operator on values it does not work with: The message says what the operator needs. - E0640 a pattern that does not fit the value: A case must be about the kind of value being matched. - E0641 a variant pattern with the wrong number of parts: Name every value of the variant, or use `_`. - E0642 a `match` that does not cover every value: The message names a missing case. Add it, or end with `_ => ...`. A case with an `if` condition does not count. - E0643 alternatives of a pattern bind different names: Bind the same names in each `|` alternative. - E0644 a `let` pattern that may not fit: Use `if (let ...)` or `match` for a pattern that may not fit. - E0645 a format that is not valid: Write `{value:[[fill]align][sign][#][0][width][,][.precision][type]}`. - E0650 a literal that does not fit a refined type: The type's condition is in the message; convert a computed value with `Port(x)`. - E0651 a refinement pattern that is not valid: The pattern after `matching` must be a valid regular expression. - W0203 a permission listed twice: Remove the repeat. - W0204 an item listed twice: Remove the repeat. - W0205 an item already covered by a wider one: Remove the narrower item. - W0401 a permission the script never uses: Ask for less. - W0402 an item of a permission that is never used: Remove it. - W0403 a permission that is broader than it looks: `run ["sh"]`, `net ["*"]` and paths outside the project allow far more than they say. - W0601 a failure used as a value without being handled: It stops the script when it happens. Add `?` or `or`. - W0602 `or` or `??` on a value that cannot fail or be empty: Remove the `or` or `??` part. - W0603 the failure of a call is ignored: Add `?`, or `or { ... }` to say what to do. - W0642 a case that can never be reached: An earlier case already matches everything. - R0100 the script stopped while running: A step failed and nobody handled it, a pattern did not fit, a number was too large, or a limit was reached. - R0101 a permission was denied while running: The script tried something its `needs` does not allow; exit code 3. ## Grammar ```ebnf program = [ needs NL ] { toplevel NL } ; needs = "needs" "[" [ entry { ( "," | NL ) entry } [ "," ] ] "]" ; entry = ( "read" | "write" | "run" | "net" | "env" ) list | ( "clock" | "random" ) [ list ] ; (* bare = granted; [] = none *) list = "[" [ item { "," item } [ "," ] ] "]" ; item = PATH | STRING | IDENT ; (* IDENT only in a function's needs *) toplevel = stmt | decl | "use" STRING | "test" STRING block ; (* `use`, decl and `test` at the top only *) decl = "type" IDENT "=" type [ "where" range | "where" "len" range | "matching" STRING ] | "enum" IDENT "{" variant { "," variant } "}" | "error" IDENT [ "(" param { "," param } ")" ] [ "<:" IDENT ] ; variant = UPPER-IDENT [ "(" type { "," type } ")" ] ; range = INT ".." [ "=" ] INT | INT ".." | ".." [ "=" ] INT ; stmt = ( "let" [ "mut" ] | "mut" ) ( IDENT [ ":" type ] | pattern [ ":" type ] ) "=" expr | target ( "=" | "+=" | "-=" | "*=" | "/=" | "%=" ) expr (* target = IDENT { "." IDENT | "[" expr "]" } *) | "for" "(" ( IDENT | pattern { "," pattern } ) "in" expr ")" block | "while" "(" ( expr | "let" pattern "=" expr ) ")" block | "break" | "continue" | "return" [ expr ] | "defer" expr | "fn" IDENT "(" [ param { "," param } [ "," ] ] ")" { "needs" needs-list | "->" type } block | expr ; param = IDENT [ ":" type ] ; type = IDENT [ ( "<" | "[" ) type { "," type } ( ">" | "]" ) ] | "(" type { "," type } ")" ; block = "{" { stmt NL } "}" ; expr = or-else ; or-else = coalesce { "or" ( block | coalesce ) } ; (* `error` is the failure inside a block: error.kind, error.message *) coalesce = logic-or [ "??" coalesce ] ; logic-or = logic-and { "||" logic-and } ; logic-and = equality { "&&" equality } ; equality = compare { ( "==" | "!=" ) compare } ; compare = range-ex { ( "<" | ">" | "<=" | ">=" ) range-ex } ; range-ex = additive [ ( ".." | "..=" ) additive ] ; (* a..b is range(a, b) *) additive = term { ( "+" | "-" ) term } ; term = unary { ( "*" | "/" | "%" ) unary } ; unary = ( "!" | "-" ) unary | postfix ; postfix = primary { "?" | "." ( IDENT | INT ) [ "(" args ")" ] | "?." IDENT | "[" index "]" | "(" args ")" } ; (* "[" and "(" with no space before; `x.f(a)` is `f(x, a)` *) index = expr | [ additive ] ( ".." | "..=" ) [ additive ] ; (* a slice *) primary = INT | FLOAT | "true" | "false" | STRING | PATH | IDENT [ "(" args ")" ] | ( "read" | "write" | "run" | "net" | "env" ) list (* a capability value, "[" glued *) | "(" expr ")" | "(" expr "," { expr "," } ")" (* a tuple *) | "[" [ args ] "]" | record | "#" "{" [ expr ":" expr { "," expr ":" expr } ] "}" (* a map *) | "#" "[" [ args ] "]" (* a set *) | "fn" "(" [ param { "," param } ] ")" [ "->" type ] block (* a closure *) | "if" "(" ( expr | "let" pattern "=" expr ) ")" block [ "else" ( if | block ) ] | "match" "(" expr ")" "{" { pattern [ "if" expr ] "=>" ( block | expr ) ( "," | NL ) } "}" ; record = "{" [ IDENT ":" expr { "," IDENT ":" expr } ] "}" | "{" ".." expr { "," IDENT ":" expr } "}" ; pattern = alt { "|" alt } ; alt = "_" | IDENT | INT | "-" INT | INT ( ".." | "..=" ) INT | STRING | "true" | "false" | UPPER-IDENT [ "(" pattern { "," pattern } ")" ] | "(" pattern { "," pattern } ")" (* a tuple *) | "[" [ pattern { "," pattern } ] [ ".." [ IDENT ] ] "]" (* a list, with a rest *) | "{" IDENT [ ":" pattern ] { "," IDENT [ ":" pattern ] } [ ".." ] "}" ; STRING = '"' { char | "\n" | "\t" | "\"" | "\\" | "\{" | "\}" | "\xNN" | "\u{N}" | "{" expr [ ":" spec ] "}" } '"' | 'r' { '#' } '"' { any } '"' { '#' } | '"""' { any } '"""' ; PATH = ( "./" | "../" | "~/" | "/" ) { path-char | "{" expr "}" } | "." | ".." ; INT = digits | "0x" hex | "0b" bin | "0o" oct ; (* with _ between digits *) FLOAT = digits "." digits [ exp ] | digits exp ; ```