The programming language for agents.
Not a wrapper around your prompts. A language whose compiler and runtime enforce what an agent may do,
putting permissions, spending limits and crash recovery in the language instead of in code you have to keep right.
The same support agent, built twice: in Python alone and in Python with Staal, with the same limits, permissions, retries and crash recovery. A model reads a ticket and proposes at most one refund (through a simulated Stripe) and one email. Both versions face 47 scenarios: policy, prompt injection, malformed model output, permission escalation, failing services and lost answers, and crashes before and after every external call. A scenario passes when the effects the simulated world ends up with are ones the scenario allows and no rule of the specification broke.
| Metric | Python alone | Python with Staal |
|---|---|---|
| Attack and crash scenarios passed | 47 of 47 | 47 of 47 |
| Code to write and keep | 312 lines | 159 lines |
| Risky later changes that shipped broken | 6 of 6 | 0 of 6 (4 refused before running, 2 held at run time) |
| Operator interventions (emails in doubt) | 4 | 4 |
Code is counted as lines that are neither blank nor comments. Neither version has tests of its own; the 47 scenarios and the simulated world are shared and not counted. The Python version’s 312 lines are one file that holds, among the rest, its own journal (78 lines), deciding whether a request was sent (20), calling and checking the model (60), and the policy and actions (82). The Staal version’s 159 are the agent (39), the app’s grants, run, resume and resolve (59), and the Stripe and mail libraries (61), which a team would take from packages, leaving 98.
The six later changes, made identically to both versions:
| Change | Python alone | Python with Staal |
|---|---|---|
| Put the ticket into the model’s instruction | ships; all 47 scenarios break the rule | refused before it runs (E006) |
| Drop validation of the model’s plan | ships; 4 scenarios act on malformed plans, 1 crashes the job | refused before it runs (E001) |
| Ask the model again to polish the email | ships; 24 scenarios go over the budget | refused before it runs (E004) |
| Log every request, headers included | ships; the API keys land in a log | refused before it runs (E101) |
| Refactor each step into its own helper | ships; two refunds go through where one is allowed | runs; the limits still hold |
| Resend the email if the mail service fails | ships; a customer is emailed twice when an answer is lost | runs; the limits still hold |
This is one agent, one specification and six changes. Both versions were written by one person who knew the scenarios in advance, which favours the Python version. Careful Python can meet the same specification, and on the day it is written it does. What the benchmark measures is how much code has to stay right for those guarantees to hold as the system changes. Three control changes, to the policy or the order of business (for example raising a limit), come out the same in both versions, because neither language can tell what the business meant. bench/agent has the code, every scenario, the controls and the rest of what this does not show.
Agents should not act on prompts and hope. Staal is a programming language built for what agents do: moving money, sending messages, changing files and records, and calling AI models.
The work is split in two. Before a program runs, the compiler checks that it follows the language’s safety rules: every action has a capability in scope, no capability is widened, a model’s output is validated before it is used, untrusted text never becomes a model’s instruction, and model use cannot exceed its declared budget. While it runs, the capability broker enforces the permissions and limits your application actually granted, and a journaled run records every external effect, so after a crash it resumes without blindly repeating a payment or a message.
Staal is said “stahl”: it is Dutch and Afrikaans for steel. This repository holds
staalc, the Staal compiler and reference runtime, written in Rust.
You do not build your application in Staal. Your API, frontend, database layer and business logic stay in the languages and frameworks you use now. Staal is for the paths where an AI agent takes actions with consequences: a support agent that issues refunds, an agent that sends SMS, email or Slack messages, one that changes records in PostgreSQL or SQLite or files on disk, or one that calls any HTTP API through a library capability. Your application decides what each run may do; Staal checks the program and enforces those permissions while it runs.
Keep your app in Python or Node.js; write the part that moves money or messages in Staal. Your app decides the policy, Staal enforces it:
pip install staal # or: npm install @staal/node
refund.staal, the part that moves money:
// Refunds an order, never by more than the app allows.
fn main(order: Text, amount: Money, pay: cap payments.refund(each: 500 USD)) -> Text {
let receipt = pay.refund(order, amount)
return "Refunded " + to_text(amount) + " for order " + order
}
app.py, your app, granting each run a limit by customer tier:
import staal
lib = staal.Library()
LIMIT = {"gold": 500, "standard": 50} # USD per refund, decided by your app
def refund(tier, order, amount):
return lib.run("refund.staal", {
"entry": "main",
"inputs": {"order": order, "amount": {"amount": amount, "currency": "USD"}},
"grants": {"pay": {"cap": "payments.refund", "each": {"amount": str(LIMIT[tier]), "currency": "USD"}}},
})
print(refund("standard", "4417", "35.00")["result"])
print(refund("standard", "4417", "300.00")["failure"]["message"])
Refunded 35.00 USD for order 4417
'payments.refund' allows at most 50.00 USD per action; this action needs 300.00 USD
The tier comes from your application’s own records, such as the signed-in
customer’s account, never from the model. This example shows capability enforcement
only; to make a run crash-safe, give it a journal
(lib.run(..., journal="refund-4417.journal")), and a crashed run then continues with
lib.resume.
No account or key is needed: with no payment service configured, Staal uses a
simulated one. The same in Node.js, and both files in full, are in
examples/quickstart/; the test suite checks this output. Next, the
tutorial adds a model’s decision, and the guide’s “Policy from
your app” shows the pattern in depth.
Safety checked at compile time. Eight rules (E001 to E008) reject a program that would act without a capability, widen a capability, use a model’s output without validating it, let untrusted text instruct a model, exceed a model budget, call a model inside an unbounded loop, leak data marked as blinded to a model, or claim completion without evidence.
Authority you can read. An action needs a capability in scope, and the
capability’s limits (only, uses, each, max) are enforced by a broker
before the action runs. A package declares the authority it needs in its
manifest, and the compiler refuses code that reaches beyond it.
Exact money, bounded everything. Money is a decimal with a currency, never floating point. Overflow, division by zero, mixed currencies and out-of-range indexes are failures, not wrong answers. Every list declares a maximum length.
Bounded model use. A function declares a budget of model calls and tokens
(budget(calls: 1, tokens: 400)). The compiler refuses a function whose worst-case
use can exceed it (E004) and a model call inside an unbounded loop (E005). At run
time, a call that the remaining budget cannot cover is refused before it is sent, and
the budget is charged from the usage the provider reports. Untrusted data is sent to
the model in a section marked as data, never as its instruction (E006), data marked
blinded never reaches a model (E007), and a model’s output is a Proposed value
that must pass validate before it can reach an action (E001).
Crash recovery built into the runtime. A journaled run (--journal, or
journal= from Python and Node.js) records every external effect before it happens
and after its outcome is known. If the process crashes, the run resumes from the
journal without repeating anything whose outcome it recorded. When an action may
have happened but its outcome was not recorded, it is never guessed: an action the
service deduplicates by key is made again on resume with the same idempotency key,
so the service returns its original result, and any other action stops the run until
an operator records what happened (staalc resolve).
Native code and standalone executables. Pure functions compile to machine
code through Cranelift, with results, errors and journals identical to the
interpreter. staalc build links a program into a single executable.
Callable from any language. A C interface (include/staal.h) runs programs
from other languages, with packages for Python (python/) and for Node.js and
TypeScript (node/): your application decides the policy, Staal enforces it
(GUIDE.md, “Policy from your app”). staalc schema prints the JSON Schemas of a
function’s inputs and result, so a program can be described as a tool to agent
frameworks, and staalc mcp serves programs as tools to any Model Context Protocol
client, with each call an ordinary run under the grants the operator’s manifest gives,
plus quotas across calls and once for calls that must not run twice
(docs/MCP.md).
An explicit boundary with native code. A program can call C libraries, granted by the library’s SHA-256 and each function’s exact signature. Staal cannot check what C code does, so every run granted a C function is marked unchecked: in its log, on the command line and in the C interface’s result. The guarantees above end at that boundary, and Staal says so where it applies.
type Line { item: Text, price: Money, qty: Int }
fn line_total(line: Line) -> Money {
return line.price * line.qty
}
fn total(lines: List<Line, 20>) -> Money {
let sum = 0.00 NGN
for line in lines {
set sum = sum + line_total(line)
}
return sum
}
$ staalc run examples/invoice/invoice.staal --config examples/invoice/run.json
{"amount":"187501.50","currency":"NGN"}
The language guide covers capabilities, models, budgets, evidence, modules, packages and the rest.
Without Rust, from a release, on Linux (x86_64 or arm64) or macOS:
curl -fsSL https://raw.githubusercontent.com/laurells/staal/main/install.sh | sh
install.sh downloads the latest release’s archive for your machine,
refuses it unless its SHA-256 matches the release’s, unpacks it into ~/.staal, and
links staalc into ~/.local/bin. Or unpack the archive yourself and run its
staalc, or install the wheel with pip install staal-X.Y.Z-py3-none-<platform>.whl,
which gives the staalc command and the staal Python package
(docs/RELEASING.md says what a release holds). Releases are
made for Linux on x86_64 and arm64 (glibc 2.28 and later) and macOS on Apple
Silicon (11 and later) and Intel (10.12 and later); arm64 Linux from 0.24.0.
From source: Rust 1.89 or newer (rustup.rs), git to
fetch packages, and a C compiler (cc) to build executables. Developed and
tested with Rust 1.97.0.
git clone https://github.com/laurells/staal.git
cd staal
cargo build --release
./target/release/staalc check examples/invoice/invoice.staal
./target/release/staalc run examples/invoice/invoice.staal --config examples/invoice/run.json
To build without the native compiler (smaller, interpreter only):
cargo build --release --no-default-features.
| Command | What it does |
|---|---|
staalc check <file.staal> |
Checks a program and reports errors by code |
staalc run <file.staal> [--entry <fn>] [--input <name>=<value>]... |
Runs a program that needs no grants, from the command line |
staalc run <file.staal> --config <run.json> |
Runs a program with the inputs, grants and model a run config gives; add --journal for crash-safe runs and --engine native for native code |
staalc config <file.staal> [--entry <fn>] |
Prints a run config to fill in for a program |
staalc schema <file.staal> [--entry <fn>] |
Prints the JSON Schemas of a function’s inputs and result, for describing it as a tool |
staalc mcp <manifest.json> |
Serves programs as tools to MCP clients, on standard input and output (docs/MCP.md) |
staalc mcp status <manifest.json> |
Lists a server’s unfinished runs and each tool’s quota use |
staalc resume <file.staal> --config <run.json> --journal <run.journal> |
Continues a run that stopped or crashed |
staalc resolve --journal <run.journal> ... |
Records an operator’s decision on an action left in doubt |
staalc fetch [<project folder>] |
Fetches the packages named in staal.json and writes staal.lock |
staalc update [<project folder>] [--package <name>] |
As fetch, moving locked versions to the newest within their ranges |
staalc publish --registry <folder> --git <location> --commit <id> |
Adds a package version to a registry |
staalc yank --registry <folder> --package <name> --version <v> [--undo] |
Marks a published version not to be chosen again |
staalc build <file.staal> -o <app> |
Builds a standalone executable (Linux) |
staalc fmt [--check] <file.staal>... |
Formats files in place, or lists those not formatted |
staalc test [--config <test.json>] <file or folder>... |
Runs the tests written in Staal, in both engines |
staalc doc <file.staal> [-o <out.md>] |
Writes the program’s documentation in Markdown |
staalc repl |
Runs Staal one input at a time |
staalc profile <file.staal> --config <run.json> |
Runs a program and shows what each function spent |
staalc debug <file.staal> --config <run.json> |
Runs a program, stopping at breakpoints and steps |
staalc lsp |
A language server for editors: the same errors as check, completion, formatting |
Exit codes: 0 success; 1 check errors or a failed run; 2 a usage, file,
config or journal problem (nothing was run); 3 the run stopped and can be
continued.
| Document | Contents |
|---|---|
| docs/TUTORIAL.md | Staal in one hour: from install to a program that sends messages and asks a model |
| docs/GUIDE.md | The language: rules, syntax, capabilities, packages, native code |
| docs/RUNTIME.md | The runtime specification: semantics, the journal, failure behaviour |
| docs/MCP.md | staalc mcp: the manifest, the protocol, quotas, once, crashes and operators |
| docs/RELEASING.md | How a release is made, and what it holds |
| editors/vscode | The VS Code extension: highlighting and the language server |
| docs/GRAMMAR.md | The complete syntax, checked against the parser by the tests |
| examples/README.md | Programs that call Claude and send real SMS and email, and how to run them with your keys |
| packages/README.md | The reference packages and how a service becomes a library |
| include/staal.h | The C interface |
| CHANGELOG.md | What changed in each version |
Staal is pre-release and not yet ready for production use. Built and tested:
the compiler and all eight safety rules, the reference runtime, Claude as a
model provider, exact money, the capability broker, budgets, the crash-safe
journal, modules, HTTP, file, TCP, SQLite and PostgreSQL capabilities,
capabilities declared by libraries, packages with versions and registries, an MCP
server (staalc mcp, checked against the official Python and TypeScript SDKs),
fetched from git and locked by hash, native code, executables, the C interface,
and calling C libraries (runs that do are marked unchecked); enums and match, optional values, tuples, maps
and sets, typed errors, methods, generic functions, interfaces, function values and
closures; and a standard library of text, number, list, hashing, encoding and date
functions, with printing, the clock and random numbers journaled so a resumed run
replays them.
Platforms: CI runs the whole test suite on every change on Linux (x86_64 and arm64)
and macOS (Apple Silicon and Intel), including the Python wheel installed and used
with no Rust. Two features are Linux only so far and refuse elsewhere: staalc build
and the db.sqlite capabilities. The Node.js package’s crash-injection tests run on
Linux. Releases, the PyPI wheels and the npm package cover all four platforms.
Not yet done: runs against the live services (Claude, Termii, Stripe, Resend,
Slack) with real keys, which so far are tested against local stand-ins; other
model providers; Windows; staalc build and SQLite outside Linux; MongoDB; and an
independent security review. docs/DEFERRED.md lists the work decided on and
put off.
src/lexer.rs, parser.rs, ast.rs source text to syntax tree
src/checker.rs, budget.rs type and safety rules, worst-case model cost
src/modules.rs, packages.rs modules, manifests, staal.lock, fetching from git
src/pattern.rs, targets.rs `only` patterns and canonical targets
src/interp/ reference runtime: interpreter, money, broker,
journal, HTTP, files, model providers (Claude)
src/native.rs native code through Cranelift
src/capi.rs, include/staal.h the C interface
src/cli.rs, main.rs the command line and built executables' entry
python/staal/ Python wrapper over the C interface
docs/ language guide and runtime specification
examples/ example programs and libraries
packages/ reference packages: sms, payments, email, chat
site/ the landing page, published to GitHub Pages
tests/ fixtures, runtime cases, crash and engine tests
cargo test --features failpoints # every test, with crash injection
cargo test --no-default-features # without native code
cargo clippy --all-targets --features failpoints -- -D warnings
scripts/audit.sh # dependency advisories (needs network)
tests/fixtures/pass/ holds programs that must compile and
tests/fixtures/fail/ programs that must be rejected, each starting with
// expect: CODE. To add a rule, write the failing program first, then make the
checker reject it. CI runs every test on Linux, Apple Silicon and Intel macOS.
CONTRIBUTING.md says what a change needs, and
fuzz/README.md how to run the fuzzers.
The reference packages are also published on their own, so projects can depend on them by git URL and commit: staal-sms, staal-payments, staal-email and staal-chat.
Copyright 2026 Laurels Ozy Echichinwo. Licensed under the
Apache License, Version 2.0, with the LLVM Exceptions
(Apache-2.0 WITH LLVM-exception), as LLVM, Cranelift and Wasmtime are. The
exceptions mean an executable staalc build makes, which holds parts of the
runtime, needs no attribution, and that the code can be combined with GPLv2
software. Contributions are made under the same terms (CONTRIBUTING.md).
The licence grants no right to the name “Staal”.