Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Aura is a configuration language that compiles to JSON, YAML or TOML. It is built around three promises:

  1. Deterministic by construction. Two runs produce byte-identical output. The language has no now(), no randomness and no implicit access to the environment — everything external (files, environment variables) arrives only through rights granted explicitly at the point of running it.
  2. Supply-chain safety. An imported package physically cannot read your files or environment variables. The capability model is Deno’s idea, applied more strictly: even calling a package’s exported function runs with the package’s rights rather than yours.
  3. Validated before deploy. Typed schemas, assert invariants and static dead-code analysis mean configuration mistakes surface at build time instead of in production.

What it looks like

type Service
  name: String
  port: Int
end

base_port = 8000 # a private computation
is_prod   = env("APP_ENV", "dev") == "production"

api: new Service # a property — this reaches the output
  name: "api"
  port: base_port + 1
end

replicas: is_prod ? 3 : 1
assert base_port > 1024, "privileged ports are not allowed"
aura eval app.aura --allow-env=APP_ENV

Who it is for

  • DevOps and SRE: Kubernetes manifests, CI matrices, service configs — without copy-pasted YAML, and schema-checked before kubectl apply.
  • Platform teams: publishable packages of standards (pub def / pub type), versioned and locked by integrity hash.
  • Application developers: one .aura declaration becomes JSON, YAML or TOML for any consumer; in Rust it embeds directly as a library.

Where things are

  • This book is the tutorial and reference for using the language.
  • SPEC.md is the formal specification, for anyone implementing it.
  • examples/ holds working examples with their expected output.

This book is also available in Russian. (The path is relative to the book’s root: the Russian translation is published under /book/ru/.)

Quick start

Installing

The fastest way to try the language is to install nothing: the playground runs the real compiler in your browser.

For a binary:

cargo install aura-lang

Or download a build for your platform from Releases — Linux (gnu and static musl), macOS (Intel and Apple silicon) and Windows, each with a .sha256. On x86_64 Linux prefer the musl build: it is static, so it has no glibc floor.

In GitHub Actions:

- uses: aura-config/setup-aura@v1
- run: aura check deploy.aura --strict

To build from source instead:

git clone https://github.com/aura-config/aura-lang && cd aura-lang
cargo build --release
# the binary: target/release/aura

Your first manifest

Create hello.aura:

app_name = "hello"
port     = 8080

service:
  name:     app_name
  url:      "http://localhost:#{port}"
  replicas: 2
end
$ aura eval hello.aura
{
  "service": {
    "name": "hello",
    "url": "http://localhost:8080",
    "replicas": 2
  }
}

Notice that app_name and port — declared with = — did not reach the JSON: they are private computations. Only properties (name:) and blocks are exported. The next chapter goes into that.

The three commands you need daily

aura eval app.aura            # evaluate → JSON on stdout
aura check app.aura --strict  # check without evaluating (a linter; for CI)
aura fmt app.aura             # canonicalise the layout

Structure without the pain

Aura has no significant indentation — this is where YAML scar tissue heals. Structure comes from line breaks and an explicit end:

server:
  http:
    port: 8080
    timeouts:
      read: "30s".parse_duration()
    end
  end
end

Indentation is for humans only. aura fmt canonicalises it automatically and is guaranteed not to change meaning: the token stream before and after is identical.

Computing and exporting: = versus :

The one rule that sets Aura apart from YAML and JSON — the same distinction as locals versus outputs in Terraform:

FormMeaningIn the output?
x = expra private variableno
key: expra propertyyes
domain "name" ... enda blockyes (the key is the name)
base = 8000     # a computation
port: base + 80 # output: {"port": 8080}

This is what makes dead-code analysis exact: an unused = variable is always genuine litter (W0501), never “perhaps someone’s output”.

Immutability

Assigning twice in the same scope is an error:

x = 1
x = 2 # E0301: variable 'x' is immutable

Shadowing is explicit

Redefining an outer variable inside a nested block requires saying so:

log_path = "/var/log/app.log"

domain "debug"
  log_path        = "/tmp/debug.log" # E0302: shadow is required
  shadow log_path = "/tmp/debug.log" # fine — the intent shows up in the diff
  path: log_path                     # → "/tmp/debug.log"
end

“Why is the path different in production?” stops being a silent surprise: shadowing is always visible as the word shadow.

Long numbers

Group the digits with _ where it helps the eye:

max_bytes: 10_000_000
retention: 2_592_000
port:      8_080
rate:      0.000_001 # in a fraction the groups run away from the point

It is entirely optional: 2371933 and 2_371_933 are the same number, and nothing asks you to group anything.

When you do, the separator has to group by threes, counted away from the decimal point. The short group therefore sits at the front of an integer and at the end of a fraction: 8_080, 1.123_45.

That is stricter than Go, Ruby or Rust, where 2_3_7_1_9_3_3 is legal. Two reasons. A separator free to sit anywhere is a second way to write one number, and this language spends its rules removing those. And it misleads: 1_0000_000 is ten million, but anyone scanning for threes reads something far larger.

100_, 1__0 and 1_.5 are errors too — there is nothing to separate.

This exists because configuration is where long numbers actually live, and a miscounted zero is the mistake nothing else catches — the value is valid, the type is right, and the service quietly gets ten times what it should.

Strings and interpolation

name = "auth"
image: "company/#{name}:v#{1 + 1}" # → "company/auth:v2"

Inside #{...} the ordinary expression syntax applies, and quotes in nested strings need no escaping — "#{list.join(", ")}" is valid.

A null has no text form, so interpolating one is E0324 rather than the word null:

host = env("HOST", null)
url: "https://#{host == null ? "localhost" : host}/api"

The guard is spelled out because which text stands in for a missing value is a decision about the configuration. Printing null would put those four letters into the output, where whatever reads it next sees a setting rather than a missing one — and the usual reason a null reaches a string is a value that got lost upstream.

Reaching into data

A dot is for fields; brackets are only for list indices:

version:  cfg.package.version         # ordinary keys
special:  cfg."key with a space"      # any key, as a string
dynamic:  cfg.envs."#{region}"        # a computed key
first:    apps[0].name                # an index (out of range is E0317)
optional: cfg.get("maybe", "default") # a miss returns the default, not an error

A typo in a field name is error E0308 with an exact position, not a silent null.

Blocks and scopes

Configuration is rarely flat. Aura has two forms of nesting, and they differ not in what they output but in what you are allowed to write inside.

FormWhat goes insideIn JSON
key: … endproperties only — dataa nested object
domain "name" … endproperties and computations (=, shadow, assert, def)an object under the label as key

An object literal: just data

security:
  tls:         true
  min_version: "1.3"
end
{ "security": { "tls": true, "min_version": "1.3" } }

Only properties are allowed inside. If you need computations, use domain.

On one line

A short object fits on one line: the same block, with commas where the newlines were (D38).

resources:
  requests: cpu: "250m", memory: "512Mi" end
  limits:   cpu: "500m", memory: "1Gi" end
end

A comma separates two fields and nothing else - one missing, or one before end, is E0205, and the message says which. aura fmt keeps whichever form you wrote. Two ends on a line (a: b: 1 end end) are legal and hard to read: write the outer level as a block.

Inside a list the one-line form needs a schema, new Port name: "http", port: 80 end - there the comma also separates the elements, and the schema checks the fields besides.

Bound with =

= binds an object exactly as : outputs one (D10): the block is the same, and it stays out of the result.

defaults =
  port:    8080
  timeout: "30s"
end

limits = cpu: "500m", memory: "1Gi" end

server:
  port:    defaults.port
  timeout: defaults.timeout
end
{ "server": { "port": 8080, "timeout": "30s" } }

Change defaults = to defaults: and the object is output as well; nothing else about it changes. If an = block is left without its end, the error points at the line with =.

domain — a section with its own scope

The label becomes the key, and the body is a full scope: =, shadow and assert all work.

domain "prod"
  replicas: 3
  region:   "eu-central"
end
{ "prod": { "replicas": 3, "region": "eu-central" } }

The label can be any expression, not only a string literal.

Scope and shadow

Outer variables are visible, but overriding one requires an explicit shadow (D7):

port = 80

domain "prod"
  shadow port = 443 # without `shadow` this is E0302
  listen: port      # 443
end

fallback: port # 80 — nothing outside changed

This is the direct answer to “why is the port different in production”: the override is visible in the code.

Lists of objects: map, with no extra keywords

A lambda body is a scope too (D17), so list elements are described directly, private computations included:

services: ["api", "web"].map (n, i) ->
  base = 8000 # private: never reaches the JSON
  name: n
  port: base + i
end
{
  "services": [
    { "name": "api", "port": 8000 },
    { "name": "web", "port": 8001 }
  ]
}

The name field here is an ordinary property that you write yourself. A component keyword used to insert that line implicitly; D17 removed it, because the language should not add fields behind your back.

Which to use

  • Data only → key: … end.
  • Data plus computation → domain.
  • A list of objects → map with a lambda body; no block needed.

The general rule: code bodies (def, a lambda, domain) are scopes; an object literal is data.

Schemas and validation

Schemas

type declares a schema; new instantiates one with checking:

type Container
  name:  String
  image: String
  port:  Int
end

main: new Container
  name:  "gateway"
  image: "company/gateway:2.4.1"
  port:  8080
end

A short instance fits on one line, with commas between the fields (D38). It is checked exactly as the block form:

sidecar: new Container name: "proxy", image: "envoy:1.30", port: 9901 end

What is checked at instantiation:

SituationCodeWhen
A schema field is missingE0511always
Wrong type (Int vs String, Int vs Float)E0512always
An extra field the schema does not declareE0513--strict only

A default is checked where the schema is declared, not only where it is used. q: Int = "not an int" is E0516 at the type, because a field that callers always supply explicitly would otherwise never reach new with its default, and the declaration would go on claiming something impossible.

Only literal defaults are judged — what an arbitrary expression evaluates to is not knowable before it runs, and new still checks the result.

type Quota
  # `Int? = null` — the marker admits null, the default makes the field
  # optional. The marker alone would still require the field at `new`.
  limit: Int? = null
end

Field types are String, Int, Float, Bool, List and Object. Int and Float are separate types, so memory limits in bytes and 64-bit identifiers keep their precision — overflow is E0304, not a silent wrap.

Fields that may have no value

A field holds a value of its type and nothing else. When a value is genuinely absent — an unlimited quota, a deadline nobody set — mark the field with ?:

type Plan
  id:    String
  quota: Int?
end

unlimited: new Plan
  id:    "enterprise"
  quota: null
end

This exists to kill the sentinel. Without it, “unlimited” gets written as -1 or 2147483647, and everyone has to remember which number means what and where. A sentinel is a valid value of its type, so nothing ever catches a wrong one.

? answers one question only: may the value be absent. Whether the field can be left out is the separate job of = default, and the two compose:

DeclarationOmitting itWriting null
quota: IntE0511E0512
quota: Int?E0511allowed
quota: Int = 100gives 100E0512
quota: Int? = nullgives nullallowed

A nullable field is still type-checked when it does hold a value, and aura types emits Option<i64>, number | null and *int64.

The one way ? can cost you

? widens what a field accepts, and that is exactly what makes it worth a second look when the value comes from outside:

quota: data.get("quota") # no fallback

Without ? a missing key is E0512 and the run stops, so two people with different input files find out immediately. With ? the same difference passes as null, and they ship different configurations without a word.

That combination is W0513, and under --strict it is an error:

[W0513] Warning: 'quota' is nullable and is filled from `get(key)`, so a
        missing value becomes null instead of an error
          Help: name the value that stands for absence: `get(key, fallback)`
                — or drop the `?` so a missing one is reported

env(name) without a fallback is the same case. Naming the fallback is all the warning asks for: data.get("quota", 0) says which value means absence, and says it in the manifest rather than in someone’s head.

Lists with an element type

List on its own accepts any elements. Write [T] to say what is in it:

type Service
  name:      String
  tags:      [String]
  endpoints: [Endpoint]
end

The type mirrors the value: [1, 2] is a list, so [Int] is its type. Nesting follows with no new rule, [[Int]], and an enum works as the element type.

A wrong element is reported with its index, because “one of these is wrong” is no help in a list of forty:

[E0512] Error: endpoints[0] of schema Service expects Endpoint, got String

This also reaches the host. aura types emits Vec<Endpoint>, Endpoint[] or []Endpoint instead of an untyped array.

Bare List keeps its old meaning, so nothing written before this needs changing.

A field typed by a schema holds what new would accept

A field typed Port, [Port] or [[Port]] holds values that new Port would accept - wherever they came from (D41). An element read from JSON, YAML or a host is checked by the same rules as one written with new: required fields, types, enums and invariants.

type Port
  number: Int
  protocol: String = "tcp"

  assert number > 0, "port must be positive"
end

type Service
  ports: [Port]
end

raw = read_file("ports.json").parse_json()
svc: new Service ports: raw end

A wrong value names the way down to it:

[E0515] Error: Port: port must be positive (in ports[2])
[E0511] Error: missing field 'port' required by schema Endpoint (in routes[1].target)

How it behaves, and why

  • Structural, not nominal. A value carries no mark of the schema that made it, so the question is “would new Port accept this?”, not “was this made by new Port?”. Data from outside passes when it is right, without being rebuilt field by field through .map.
  • Defaults are filled in. {"number": 80} in a [Port] field becomes {"number": 80, "protocol": "tcp"}, in the schema’s field order, exactly as new would make it. One schema gives one shape of output, whatever the source - which is what aura types and a host reading the result rely on.
  • An instance made by new is checked again. The checks are pure and the defaults are already there, so nothing changes; the alternative, a hidden mark saying “already checked”, would be state a reader cannot see.
  • Extra fields follow new: kept, and E0513 under --strict.
  • A schema may hold itself - children: [Node] - to any depth up to 32 levels; deeper is E0208 rather than a crash, since data, unlike code, can be built to any depth.

Before D41 such a field took any object at all: one without number, or one whose number broke the invariant. A manifest that relied on that now stops with an error that says which element is wrong.

Rules the schema states about itself

A type says what shape the values have. It can also say what they must be:

type Plan
  price_monthly: Int
  price_yearly:  Int
  max_channels:  Int

  assert price_monthly >= 0, "price must not be negative"
  assert max_channels > 0, "a plan needs at least one channel"
  assert price_yearly <= price_monthly * 12, "yearly must not exceed twelve months"
end

These run on every new Plan, and a failure is E0515 carrying the message the schema wrote:

[E0515] Error: Plan: yearly must not exceed twelve months

The third one is the reason this is worth having: it relates two fields, which no per-field constraint can express. It needs no ordering rule either, because every field is filled and type-checked before any invariant runs.

Why not just write the assert next to the new

Because then it is not part of the type. It does not travel with the schema, so an importer gets the fields without the rules; tooling cannot see it; and the next person to instantiate the schema simply will not write it.

Put it in the schema and it holds everywhere, including in a manifest whose author never read your package:

import "./lib.aura" as lib

p: new lib.Port
  number: 80 # E0515: Port: ports below 1025 are privileged
end

What an invariant can see

Its own fields, and nothing else. Not the module the schema was declared in, not the one the new was written in:

[E0504] Error: use of undefined variable 'limit'
          Help: an invariant sees only this schema's fields (q), so it means the
                same in every manifest that uses the schema; pass anything else
                in as a field

That is deliberate. A rule that could read a module variable would mean something different in each manifest that imported the schema — which is the problem invariants exist to solve. It also means nothing effectful is reachable from one, so an instance is valid or invalid identically for everyone.

assert

Arbitrary invariants are the assert statement:

admins = [187650342, 244179081]
assert admins.uniq().len() == admins.len(), "Duplicate admin ids"
assert admins.len() >= 1

A failure is E0530, with your message and an exact position. assert also runs under --dry-run: a rehearsal has to be able to find a validation failure.

fail inside an expression

For validation in the middle of an expression there is fail:

port: p > 0 && p < 65536 ? p : fail("invalid port #{p}")

Closed sets: enum

A String field accepts any string, so a typo in a value rides all the way to production. enum declares a closed set of permitted strings:

enum Tier
  "frontend"
  "backend"
end

type Service
  tier: Tier
end

svc: new Service
  tier: "backand" # E0514, with "did you mean \"backend\"?"
end

An enum member is an ordinary string: the JSON contains "backend" and no wrapper appears. It constrains the value; it does not introduce a new data type.

pub enum is exported to importers (D12). Members resolve where the schema is declared, so an imported schema is validated against its own module’s enum — even if you have one of the same name in scope.

Static analysis

aura check — and automatically before every eval — finds problems before anything runs:

  • E0504 — use of an undeclared variable, including inside #{...};
  • W0501 / W0502 / W0503 — unused variables, imports, functions and types;
  • W0512 — an effectful call (env, read_file) in an imported module.

Under --strict warnings become errors. That is the mode for CI.

Functions and methods

def — constructor functions

def returns an object when its body is properties (and a value when it ends in one - below):

def make_env(name, replicas, debug)
  replicas:  replicas
  log_level: debug ? "debug" : "warn"
  db_url:    "postgres://db.#{name}.internal:5432/app"
end

environments:
  dev:  make_env("dev", 1, true)
  prod: make_env("prod", 6, false)
end

One def instead of three copy-pasted YAML files: this is the language’s main anti-duplication tool.

A function that returns a value

A function whose body ends in an expression returns that expression (D40). Bindings and assert may come before it, so a step can be named:

def ports_of(services)
  all = services.map (s, i) -> s.port end
  assert all.len() > 0, "no services"
  all.uniq().sort()
end

ports: ports_of(catalogue)

A multi-line lambda follows the same rule; a one-line lambda, (x) -> x * 2 end, already was an expression.

Three cases, and why each is as it is

  • Properties, or a last value - not both. A body with key: properties that also ends in an expression is E0215. Returning the value would drop the properties, returning the properties would drop the value, and either loses something without a trace. Make the value a property (value: ...) or remove the properties.
  • A value nobody uses is an error. A bare expression anywhere else - at the top of a file, in a domain, before the last line of a function - is E0214. Aura has no side effects, so a value that goes nowhere did nothing: the author almost always meant key: (output it), name = (use it later) or assert (check it), and the help names all three. Before D40 such a line was evaluated and silently dropped.
  • Only bindings and checks is still an empty object. A body of = and assert alone returns {}, as before: nothing in it was meant to be the result, so nothing is thrown away.

Both errors come from aura check, so the editor shows them as you type.

Lambdas

double = (x) -> x * 2 end
up: ["a", "b"].map (s, i) -> s.upper() end # a trailing lambda

A map or filter callback receives the element and its index; parameters you do not need can be left out.

Methods

Called with a dot, and they chain:

active: services.compact().uniq().map (s, i) -> s.upper() end

The full list is in the method reference. The ones you will reach for:

  • lists: map filter compact (drop null) uniq first last join(sep) contains(x) get(i, default)
  • objects: merge (the right side wins) keys values contains(key) get(key, default)
  • strings: upper lower len contains(sub), plus the format parsers

The ternary operator

The language’s only branching construct:

mode: is_prod ? "webhook" : "long_polling"

The condition must be a Bool. Aura has no notion of truthiness, and anything else is E0306.

Modules and packages

Imports

import "templates/defaults.aura" as defaults # a file, relative to this one
import github/acme/aura-k8s@v1.2 as k8s      # a registry package; the version is mandatory

Cyclic imports are reported with the whole chain: E0401: cyclic import: a.aura -> b.aura -> a.aura.

What a module exports

A module hands the importer an object: its properties, its blocks, and its pub items.

# validators.aura
pub type Service # the schema is part of the API
  name: String
  port: Int
end

def valid_port(p) # a private helper: invisible to importers
  ok: p > 0 && p < 65536
end

pub def service(name, port) # the function is part of the API
  name: name
  port: valid_port(port).ok ? port : fail("invalid port #{port}")
end
# using it
import "validators.aura" as v

api:    v.service("api", 8080)
worker: new v.Service
  name: "worker"
  port: 9000
end

The key safety guarantee: an exported function runs with its own module’s rights, not the caller’s. A package cannot borrow your permission to read files by getting you to call its function.

Installing packages: aura add

aura add github/acme/aura-k8s@v1.2.3           # from the network (an exact version)
aura add pkg/internal@v1.0.0 --from ./pkg.aura # from a local file

aura add is the only place Aura touches the network: the package is downloaded, validated, placed in the local cache (~/.aura/registry) and recorded in aura.lock with an integrity hash. eval always works offline.

aura.lock and –frozen

  • the lock stores the exact version and integrity hash of every package;
  • the hash covers the module’s token stream, not its bytes: reformatting it or fixing a typo in a comment does not trip the check, while renaming a field or changing a number does. An integrity check that fires on harmless edits teaches people to refresh the lock without reading it, which defeats the purpose;
  • a sha256- entry from an older version still verifies, and is upgraded the next time the lock is writable;
  • if a package’s contents change underneath it, that is E0402 integrity mismatch;
  • in CI, run with --frozen: resolution goes strictly through the lock, a missing entry is E0403, and the lock is never rewritten.

Version ranges (@v1, @v1.2) resolve against the local cache to the highest match — v1.2 selects 1.2.*.

Time without surprises

now() does not exist

Calling now() or timestamp() is error E0533. This is not an omission but a design decision (D13): the current time would make a config irreproducible, breaking --dry-run comparisons, caching, and the whole idea that two runs give the same result.

If you genuinely need the build time, the host passes it in:

built_at: env("BUILD_TIME", "unknown")
BUILD_TIME=$(date -u +%FT%TZ) aura eval app.aura --allow-env=BUILD_TIME

The decision about time is made and recorded by whoever runs the tool, and the config stays a pure function of its inputs.

Durations

Timeouts and TTLs are strings with d / h / m / s units, parsed into seconds as an Int:

ttl = "1h30m".parse_duration() # → 5400
cache:
  ttl_seconds:   ttl
  refresh_every: (ttl / 3).format_duration() # → "30m"
end

From there it is ordinary integer arithmetic: adding, subtracting, converting a day into hours. A malformed string is E0319.

Dates

RFC 3339 ↔ epoch seconds, in UTC:

start = "2026-07-18T22:00:00Z".parse_datetime()      # → an epoch Int
window:
  start: start
  end: (start + "4h".parse_duration()).format_datetime()
  # → "2026-07-19T02:00:00Z" — crossing midnight works correctly
end

Offsets are supported (+02:00 denotes the same instant), as is the short date form ("2026-07-18" is midnight UTC). A malformed date is E0320.

Higher-level calendar arithmetic — “plus three months”, working days, time zones — is deliberately outside the core. That is territory for community packages built on top of these epoch primitives.

The security model

Aura assumes configuration gets assembled out of other people’s code — packages, a colleague’s templates, snippets copied from somewhere — and builds its defences by construction rather than by agreement.

The capability model

By default a manifest can do nothing: it can read neither files nor environment variables. Rights are granted by flags at the point of running it, and they do not leak.

FlagPermits
--allow-read=<dir>read_file() inside that directory; paths are canonicalised, and .. cannot escape (E0311). A relative path is read from the manifest’s own folder (D37)
--allow-env[=A,B]env() for the named variables — with no list, all of them
--hermeticno I/O at all: env() and read_file() are E0505 in every module; refused together with --allow-*
--allow-imports-ioextend the root’s rights to imported modules

A call without the right is E0310, with a hint naming the flag to add.

Hermetic mode

--hermetic is the opposite end of the same dial: instead of granting rights, it declares that none exist. env() and read_file() become E0505 wherever they appear — root or import — and the flag cannot be combined with any --allow-*.

The distinction from simply omitting the grants is when you find out. A denied call is E0310 at runtime, on the branch that happened to execute; E0505 is an analysis error, so aura check --hermetic proves the property for the whole manifest without evaluating it. That makes it usable as a gate:

$ aura check --hermetic manifests/*.aura

CUE, KCL and Starlark are hermetic by construction. Aura makes it a mode, so one repository can hold both the manifests that must read the environment and the manifests that must not, and CI can require the strict kind per directory.

Bounded evaluation

Hermetic proves a manifest cannot take anything. It does not prove the manifest will finish: three nested range(1000).map fit in 95 bytes, pass check --hermetic, and would run for over a minute. Every evaluation therefore has a budget — the CLI, eval_file, the playground and Script alike:

BudgetExhaustedDefaultFlag
steps — expressions evaluatedE032510 million--max-steps=N
bytes — every value and frame createdE0326256 MiB--max-memory=MB

Both are counted, never timed, so the same manifest stops at the same point on every machine. The defaults are about thirty thousand times what the heaviest example in the repository spends; --stats prints what a manifest spends, so a budget can be set from a measurement.

Depth has a limit of its own, because steps do not bound it: a function calling itself a few hundred times spends few steps and a great deal of stack. A single expression nests at most 256 levels - a long chain such as a + b + c + ... counts its length - and evaluation nests at most 256 levels at run time, a recursive call counting the expressions it passes through. Past either is an error, E0208 or E0399, never a stack overflow (D42). Like the budget, the limit is a count, so it stops a manifest at the same point everywhere. A release build fits it in 512 KiB of stack, so a host may call a Script from any thread with at least that much - an iOS secondary thread has exactly 512 KiB.

Values have the same limit (D43). A loop can build a deep value without any nesting of its own - reduce wrapping its accumulator adds a level a turn - and every walk over a value, from writing it out to freeing it, recurses once per level. So a list or object deeper than 256 is E0328 where it is built, and a value from a host deeper than that is refused at the boundary. Parsed JSON and YAML already stop at 128.

Import isolation

Rights belong to the root manifest. An imported module:

  • cannot call env() or read_file() — not even when the root holds those rights;
  • does not acquire rights through its own exported functions: the body of a pub def runs with the capabilities of the module it came from, not the caller’s;
  • is flagged statically with warning W0512 if it contains effectful calls.
$ aura eval main.aura --allow-read=.
[E0310] Error: imported module has no capability to call read_file()
   ╭─[ evil_dependency.aura:2:7 ]

There is a runnable version of this in examples/security_demo/.

Host functions and reload

An application that embeds Aura can give a script its own functions (D35) and reload it while running (D36). Both are held to the model above:

  • A host function is a grant, named by the application, one function at a time. pure ones - the host’s promise of determinism - keep a script hermetic; effect ones are grants like --allow-env.
  • They reach the root module only. An imported package cannot call one unless the application grants that function to imports by name (E0310).
  • Every use is checked at load, before anything runs: a function not provided is E0007. A host function cannot call back into the script (E0010).
  • A reload is a full load with the first load’s grants and its aura.lock, frozen: an edited script cannot widen its rights, pull in an unpinned package, or drop an export the application bound (E0011). A file still being written is not loaded until it has been left alone for the settle period.

Supply chain

  • A package’s version is mandatory in the import itself: import github/acme/pkg@v1.2 as p.
  • aura.lock pins the exact version and an integrity hash of the module’s token stream; a substitution is E0402. Hashing tokens rather than bytes means a reformat or an edited comment does not fire the check, while any change that can alter behaviour does. A lock written by an older version holds a sha256- byte hash; it still verifies, and is upgraded in place on the next run that is not --frozen.
  • --frozen (the CI mode) forbids resolving outside the lock (E0403).
  • The network exists only in aura add; evaluation is always offline.

Determinism

  • now() does not exist (E0533) — the host passes time in through env.
  • Output key order is declaration order; two runs are byte-identical.
  • --dry-run performs the whole evaluation but writes neither JSON nor the lock, and reports every read: [dry-run] read: ./Cargo.toml.

Formats: TOML, JSON, YAML

Reading

The three parsers are string methods, and they return ordinary Aura objects and lists:

cargo = read_file("./Cargo.toml").parse_toml()
team  = read_file("./team.json").parse_json()
lint  = read_file("./.rules.yaml").parse_yaml()

service:
  name:  cargo.package.name
  owner: team.teams[0].lead
end

Integers from every format arrive as Int, with no loss of precision. A parse failure is E0314, carrying the underlying library’s message.

Writing

From the CLI, with a flag:

aura eval app.aura --format json       # the default, pretty-printed
aura eval app.aura --format json-flat  # flattened keys: a.b.c = 1
aura eval app.aura --format yaml
aura eval app.aura --format toml       # requires an object at the top level

From inside the language, with methods. Each format has two of them, and the choice is about who reads the result:

configmap:
  # Read by a program: the compact form, because the newlines would only be
  # escaped on the way in.
  "app-config.yaml": settings.to_yaml()
  # Read by a person, in `kubectl get cm -o yaml` or in a diff: spelled out.
  "reference.yaml": settings.to_yaml_pretty()
end

to_json / to_yaml / to_toml give the most compact form the format allows; to_json_pretty / to_yaml_pretty / to_toml_pretty give the form a person reads. Compact YAML is flow style — {a: 1, xs: [1, 2]} — which is ordinary YAML, not a dialect. Compact TOML keeps arrays on one line.

The CLI is the other way round: --format always writes the spelled-out form, because it is producing a document rather than a string that goes inside one.

TOML’s limitations become honest E0603 errors: it has no null, and it requires an object at the top level.

json-flat joins nested keys with a dot, which stays unambiguous only while no key contains one. Keys written in Aura are identifiers, so they cannot — but parse_json and friends return whatever the input held, and then {"a": {"b": 1}} and {"a.b": 2} both want the key a.b. That is E0604 rather than one value quietly overwriting the other. An empty object flattens to itself (a.b: {}); it is a leaf, exactly as an empty list is.

Aura as a converter

Because the language reads and writes all three formats, a migration is one line:

# convert.aura
config: read_file("./legacy.toml").parse_toml()
aura eval convert.aura --allow-read=. --format yaml

Unlike yq or jq, schemas, assert and merging several sources are available along the way — conversion with guarantees.

Embedding, and calling from other languages

Rust: as a library

[dependencies]
# `default-features = false` drops the `cli` feature, and with it clap, ariadne
# and ureq — which the library never uses. Measured on an empty project: 38
# packages instead of 96. The feature is on by default only so that
# `cargo install aura-lang` produces the `aura` binary.
aura-lang = { version = "0.4", default-features = false }
#![allow(unused)]
fn main() {
use aura_lang::facade::{eval_file, EvalOptions};

let opts = EvalOptions {
    strict: true,
    allow_read: vec!["config/".into()],
    ..Default::default()
};
let out = eval_file("config/app.aura".as_ref(), &opts)?;
let cfg: MyConfig = serde_json::from_value(out.json)?;      // straight into your own structs
for w in &out.warnings {
    log::warn!("{w}");                                       // Display: warning[W..]: ... at file:line:col
}
if let Some(lock) = out.updated_lockfile {
    std::fs::write("config/aura.lock", lock)?;               // writing it is the host's decision
}
}

Rights are set by the host application — the config never gets more than you granted. Diagnostics arrive structured (code, severity, file, line, column, help), so you can render them into your own log however you like.

A manifest that exists only in memory — a test harness, a browser, an editor holding unsaved buffers — goes through eval_source instead, which takes a map of name to text and resolves imports inside it.

In an async context, wrap eval_file in spawn_blocking: it reads files and evaluates synchronously.

Reading a whole manifest into your types

When the manifest is the configuration, read all of it at once, straight into your own structs - no JSON in between, and the same result aura eval prints:

#![allow(unused)]
fn main() {
#[derive(serde::Deserialize)]
struct Device { board: Board, sensors: Vec<Sensor>, /* ... */ }

let device: Device = Script::builder()
    .file("device.aura")
    .allow_read("config")                      // the files it reads, beside it
    .env_values([("APP_ENV", "production")])   // exactly these, and no others
    .load()?
    .output()?;
}

env_values grants the named variables with the given values; env() reads them from there and never from the process, so the service’s own environment cannot leak into the manifest, and a test can give it any environment without touching the process’s. aura types --lang rust writes the structs for the schemas a manifest declares. Every example in the repository is read this way in a test and compared with the output the CLI is pinned to; cargo run --example read_device (and read_k8s_deploy, read_service_catalog) shows it whole.

Calling into a script: Script

eval_file returns JSON, and JSON cannot hold a function. But an Aura pub def is a value, so rules can live in a .aura file and the application can call them — per request, per event, per batch — changing behaviour by editing the file rather than rebuilding. Script is the type for that (D33).

#![allow(unused)]
fn main() {
use aura_lang::{Budget, Script};

#[derive(serde::Serialize)]
struct Event { device: String, temp_c: f64 }

#[derive(serde::Deserialize)]
struct Summary { total: u64, alerts: u64 }

let rules = Script::builder()
    .file("telemetry.aura")
    .budget(Budget::per_call().steps(5_000_000).memory_mb(32))
    .load()?;                                                // parse, analyse, prove hermetic — once

let summarize = rules.function::<(&[Event],), Summary>("summarize")?;  // name, kind, arity — checked now

for chunk in events.chunks(10_000) {
    let s = summarize.call((chunk,))?;                       // one call, one budget, nothing left behind
}
}

What it guarantees, in the order the decision ranks them:

  • Hermetic by default. With no grants, load proves statically that the script reads no file, variable or clock, and fails with E0505 if it could — on a branch that never runs, too. allow_env and allow_read grant exactly what --allow-env and --allow-read do, and reach the root module only.
  • Every call has a budget, counted in steps and bytes — never in time, which would make the outcome depend on the machine. A runaway ends with E0325 or E0326 instead of stalling the host. The default is finite.
  • Memory per call is constant. Each call runs in its own interpreter and releases what it made when it returns.
  • The boundary is checked. Arguments cross by serde; an integer past i64 or a NaN is E0005 rather than a silently different input, and a result that does not fit your type is E0006, naming the path inside it.
  • Script is Send + Sync. One instance serves every thread.

Measured on one core: a call costs about 1.2 µs, and a rule over events processes roughly 0.9 million events a second called per event, 1.3 million in batches — cargo bench --bench script. cargo run --example embedding runs the example above end to end.

Reloading rules while the service runs

Rules change more often than the service that runs them. A script loaded from a file can be loaded again without a restart (D36):

#![allow(unused)]
fn main() {
use std::time::Duration;

let rules = Script::builder()
    .file("routing.aura")
    .watch(Duration::from_secs(2), |result| match result {
        Ok(v) => log::info!("rules at generation {} ({})", v.generation, v.hash),
        Err(e) => log::error!("rules not reloaded, the old ones keep serving: {e}"),
    })
    .load()?;

rules.reload()?;     // or reload now, with or without watch
}
  • A reload is a load. It runs every check the first load did, with the same grants: it can never make a script less hermetic, reach a host function you did not provide, or pull in a package aura.lock did not pin - the lock is read once, and a reload resolves as --frozen does.
  • The exports are a contract. Each export keeps its kind and number of arguments, or the reload is E0011 and the old version keeps serving. New exports are fine. A bound Function follows its name across versions.
  • Never half a version. A call finishes on the version it started on; the old version is freed when the last such call returns.
  • Content decides. A touch loads nothing. While watching, a change is loaded only once the files have been left alone for the settle period - two intervals and at least a second, or .settle(d) - so a file still being written is not loaded half done. A writer that pauses mid-write for longer than that can still be caught: deploy by writing a new file and renaming it over the old one, as editors do, and raise .settle for a slow network share.
  • No silent failure. The handler watch requires hears of every automatic reload - the new version, or the error - and of the same broken content once.
  • version() names the generation and a hash of every file, for your logs.

cargo run --example checkout puts it all together: an online store’s risk rules, given a GeoIP table, exchange rates and a dispute database by the service, reloaded on an edit - and refusing an edit that renames what the service calls, or that reaches for a secret it was never given.

Advanced: a script that calls its host

Some rules need data only the application has - which region an address is in, whether a feature is on for a customer. Rather than computing every such value in advance, the host can give the script named functions to call (D35). This is an advanced use: read the guarantees below before relying on it.

import "host:geo" as geo

pub def route(ip)
  region = geo.region(ip)
  backend: region == null ? "global" : "#{region}-cluster"
end
#![allow(unused)]
fn main() {
let rules = Script::builder()
    .file("routing.aura")
    .pure("geo.region", move |ip: String| geoip.get(&ip).cloned())   // Option: None is null
    .effect("flags.enabled", move |flag: String, customer: String| {
        flags.enabled(&flag, &customer)                               // Result: Err is E0008
    })
    .load()?;
}

pure and effect are methods of the Rust API, not words of the language. The part of the name before the dot is the module the script imports.

  • pure is your promise, and the language cannot check it. A pure function must give the same answer for the same arguments - a table that ships with the binary, a formula. It leaves the load hermetic. Anything that reads the world - a database, a flag service, the clock - is an effect: registering it is a grant, like allow_env, and the load is no longer hermetic.
  • The root module only. A package the script imports cannot call host functions; grant_to_imports("geo.region") opens one function to imports.
  • Checked at load. A module or function the host did not provide is E0007, a call with the wrong number of arguments is E0009 - on branches that never run, too. Argument types are checked at the call, also as E0009.
  • Budgeted. A host call costs 100 steps (host_call_steps changes it). The time spent inside your function is invisible to the budget: bound it yourself.
  • No call back. A host function that calls into the script gets E0010.

aura eval has no host, so a host: import there stops with E0007; aura check lists what the script needs from whichever application runs it. cargo run --example host_functions runs a complete example.

Any language: a subprocess

The CLI contract is stable and is an API: JSON on stdout, diagnostics with E0xxx / W0xxx codes on stderr, exit codes 0 (success), 1 (diagnostics), 2 (I/O or arguments).

import json, subprocess
r = subprocess.run(["aura", "eval", "app.aura", "--frozen"],
                   capture_output=True, text=True)
if r.returncode != 0:
    raise RuntimeError(r.stderr)
config = json.loads(r.stdout)

Recommended in production:

  • --frozen — dependencies strictly as recorded in aura.lock;
  • minimal, explicit rights (--allow-read, --allow-env=NAMES);
  • --format yaml|toml if that suits the consumer better.

Mobile and browser applications

The right pattern is to evaluate on a server or in CI:

configs.aura ──aura eval──▶ config.json ──▶ CDN / bundle
the device reads finished JSON with its platform's own parser

Applications consume the result of Aura; validation already happened in CI.

Why wrappers rather than native reimplementations

Every language has its own YAML parser, so it is fair to ask why Aura does not work that way. Because YAML is a data format: porting it means writing a parser, bytes in and data out. Aura is an evaluated language — functions, imports, schemas, capabilities, determinism — so porting it means writing an interpreter that is obliged to match this one byte for byte. Otherwise the same manifest yields different values in CI and in the service, which is to say in production. The divergence is not exotic: a naive implementation on Go’s encoding/json emits 1 where Aura emits 1.0, and 1000000000000000000 where Aura emits 1e+18.

So there is one implementation, and other languages reach it through:

  1. Evaluation at build time — the main path. No Aura runtime is needed inside your language at all, and aura types will additionally generate types for your JSON.
  2. Wrappers around the same core — on the roadmap, in this order: WASM/npm (one artifact covers Node, the browser and a documentation playground), then wazero for Go (pure Go, no cgo) and wasmtime for Python; PyO3 and UniFFI as demand appears.

The core already builds for wasm32-unknown-unknown; CI checks that on every commit, and runs the resulting module under Node so that “it compiles” is not mistaken for “it works”.

CLI

aura eval <file.aura>  [--strict] [--dry-run] [--frozen]
                       [--allow-read=<dir>]... [--allow-env[=A,B]] [--allow-imports-io]
                       [--hermetic] [--max-steps=N] [--max-memory=MB] [--stats]
                       [--format json|json-flat|yaml|toml] [-o out] [--registry-dir=<dir>]
aura check <file.aura> [--strict] [--hermetic]
aura docs --agent [-o <file>]
aura fmt <files...> [--check]
aura types <file.aura> --lang rust|ts|go [--out <file>]
aura add <path>@vX.Y.Z [--from <file>] [--registry-dir=<dir>]
aura --version

eval

Evaluates a manifest with all its imports and prints the result.

FlagEffect
--strictanalysis warnings (from every module) become blocking; extra schema fields are error E0513
--dry-runa full evaluation, but neither the output nor aura.lock is written; reports [dry-run] read/would write
--frozendependencies strictly per aura.lock (E0403 on a mismatch), and the lock is never rewritten — the CI mode
--allow-read=<dir>permits read_file() inside that directory (repeatable). A relative path in read_file() is read from the manifest’s own folder, so the output does not depend on where aura runs (D37)
--allow-env[=A,B]permits env(): either a list of names, or all of them
--allow-imports-ioextends the root’s rights to imported modules
--hermeticno I/O at all: env() and read_file() are E0505 wherever they appear. Mutually exclusive with the --allow-* flags
--max-steps=Nexpressions the evaluation may run before it stops with E0325. Default ten million
--max-memory=MBmebibytes of values it may create before it stops with E0326. Default 256
--statsprint the steps and bytes the evaluation spent, to stderr
--formatjson (default), json-flat, yaml or toml
-o, --output <file>write to a file instead of stdout
--registry-dir=<dir>the package cache directory (default ~/.aura/registry)

check

Lex, parse and static analysis only — a fast gate for pre-commit hooks and CI.

--hermetic additionally requires that the manifest performs no I/O: env() and read_file() anywhere in it, or in anything it imports, is E0505. It is decided statically, so check answers it without evaluating — including for branches that this particular run would not have taken:

$ aura check --hermetic deploy.aura
[E0505] Error: env() is not allowed in hermetic mode
   ╭─[ deploy.aura:4:11 ]

Reach for it when a manifest’s output must depend on nothing but its own text: a CI gate on a directory of manifests, or a build whose result you want to be reproducible on another machine. eval --hermetic enforces the same thing, and is the right flag when the manifest is untrusted.

A script written for an application that provides host functions (D35) imports them as import "host:geo" as geo. The CLI has no host, so eval stops with E0007; check lists what the script needs from the application that will run it, and exits 0:

$ aura check routing.aura
note: provided by the application at run time, this script calls:
  geo.region (1 argument)
  flags.enabled (2 arguments)
ok: routing.aura

docs

aura docs --agent prints the complete language reference — syntax, the standard library, every diagnostic code — assembled from the compiler’s own definitions. It is about four thousand tokens, which is small enough to hand to a coding assistant whole.

The reason to print it from the binary rather than link to a page is versions: the output describes the aura that produced it, and cannot drift the way a hosted document can.

The most useful way to use it is to let the assistant fetch it. One line in your repository’s AGENTS.md, CLAUDE.md or equivalent:

Before writing or editing .aura files, run `aura docs --agent` for the complete
language reference.

Or commit it, for assistants that read files but cannot run commands:

$ aura docs --agent -o AURA.md

The same text is published at /llms.txt for tools that fetch over the network.

fmt

The canonical formatter: indentation (two spaces per level, by token depth), intra-line spacing, and column alignment of runs of name = value, key: value and cond arms together with their trailing comments. Strings and the contents of block strings are left alone; the token stream is guaranteed not to change, and formatting is idempotent. --check only checks, exiting 1 if anything differs.

types

Generates host-language types from the type and enum declarations:

aura types config.aura --lang ts

The six built-in field types map like this:

AuraRustTypeScriptGo
StringStringstringstring
Inti64numberint64
Floatf64numberfloat64
Boolboolbooleanbool
ListVec<serde_json::Value>unknown[][]any
Objectserde_json::Map<String, serde_json::Value>Record<string, unknown>map[string]any

String is the only name the language accepts for a text field — there is no Str.

Note the last two rows. A schema’s List and Object carry no element type, so the generated code cannot either: you get a sequence or a map of untyped values. Where the shape matters, declare a type for the element and let the field hold that instead.

One schema both validates the config and types the service that reads its JSON. An enum becomes a string-literal union (TypeScript), an enum with #[serde(rename)] (Rust), or a named string with typed constants (Go). Optional fields (D15) are emitted as required, because evaluation always substitutes the default. Parsing only: the manifest is not evaluated and no capabilities are involved. --out writes to a file.

The output is already canonical for the host language’s formatter — rustfmt --check, gofmt -l and prettier --check stay silent on the generated files — so it can be committed into a service’s repository without formatting churn in diffs.

add

Installs a package: downloading it (github/<owner>/<repo> → package.aura at tag vX.Y.Z; an exact version is mandatory), validating it, writing it to the cache and recording it in ./aura.lock with an integrity hash. --from <file> installs from a local file instead. This is the only command that uses the network.

Exit codes

CodeMeaning
0success
1diagnostics (language errors, a failed assert, a blocking strict warning)
2I/O and argument errors

Keywords

Aura has sixteen reserved words. None of them can name a binding, a function, a schema or an unquoted key: type = 1 is a syntax error. A key that has to be one of these words is written as a string, and read the same way (D11): "type": "service", then o."type".

WordWhat it doesExampleDecision
importBrings in another module, a registry package with a pinned version, or a module the host providesimport "./lib.aura" as libD8, D12, D35
asNames what import brought in; only after an importimport org/net@v1.2 as netD12
typeDeclares a schema: fields with types, defaults and invariants. A field typed by a schema holds only what new of it would accepttype Port … endD15, D26, D27, D28, D41
enumDeclares a closed set of strings for a schema fieldenum Tier … endD18
defDeclares a named function. It returns the object of its properties, or its last expression when it ends in onedef labels(team) … endD17, D40
endCloses every block: objects, def, type, enum, domain, cond, new, a multi-line lambda and a text stringtls: enabled: true endD3, D38
domainA named section with its own scope: its label becomes the key, and its body may computedomain "production" … endD17
newBuilds a schema instance and checks itnew Port number: 443 endD4, D38
assertA check that stops evaluation with a message; inside a type, an invariant carried with the schemaassert replicas > 0, "need one"D5, D28
shadowRebinds a name from an outer scope - without it, that is E0302shadow port = 9090D7
pubMakes a def, type or enum importable; properties are always visiblepub def service(name)D12
condA choice between several values, the first true arm winscond … else -> "dev" … endD14
elseThe last arm of a cond, required so every path has a valueelse -> "dev"D14
trueThe Bool true. There is no truthiness: a condition that is not a Bool is E0306tls: true-
falseThe Bool falsedebug: false-
nullThe absence of a value. A schema field takes it only when its type is marked ?, and it is never rendered as textquota: Int? = nullD27, D31

Not reserved

WordWhat it doesExampleDecision
textOpens a block string, but only where a value is expected - key: text or x = text at the end of a line. Everywhere else it is an ordinary name, so a field called text keeps workingbody: text … endD16

The type names - String, Int, Float, Bool, List, Object - are not keywords either: they are names the schema checker knows. The decisions in the last column are in the decision log of the specification.

Built-in methods

Methods are called with a dot and they chain. Every signature below was checked against the implementation rather than transcribed.

String

MethodResultNotes
upper() / lower()String"auth".upper() → "AUTH"
len()Intcounts characters: "héllo".len() → 5
trim()Stringstrips surrounding whitespace
split(sep)List"a,b".split(",") → ["a", "b"]
replace(from, to)Stringevery occurrence
starts_with(prefix) / ends_with(suffix)Bool
contains(substr)Bool"hello".contains("ell") → true
to_int() / to_float()Int / Floata malformed number is an error, not null
slice(start, end)Stringsubstring by character index, end exclusive, indices clamped
to_str()Stringidentity, for use in chains
parse_json() / parse_yaml() / parse_toml()Objectintegers become Int; a failure is E0314
↳ fidelitykey order is the document’s; YAML << is merged; a TOML datetime becomes a String
parse_duration()Int (seconds)"1h30m" → 5400; units d/h/m/s; E0319
parse_datetime()Int (epoch UTC)RFC 3339 or YYYY-MM-DD; offsets ±HH:MM; E0320
sha256()Stringlower-case hex digest
base64() / base64_decode()Stringstandard alphabet with padding; invalid input is E0321

Int

MethodResultNotes
abs()Int
to_float()Floatwidening; there is no Float.to_int(), which would have to round
to_str()String
format_duration()String5400 → "1h30m"; 0 → "0s"
format_datetime()StringRFC 3339 UTC: 946684800 → "2000-01-01T00:00:00Z"

Float

MethodResult
abs()Float
round(digits)Float
to_str()String

Bool

MethodResult
to_str()String

List

MethodResultNotes
len()Int
first() / last()elementan empty list is E0317
get(i) / get(i, default)elementa miss gives null, or the default if you supply one
contains(item)Boolstructural equality
compact()Listdrops null
uniq()Listdeduplicates, keeping first occurrences in order
map (x, i) -> ... endListthe callback receives the element and its index
filter (x, i) -> Bool endLista non-Bool from the callback is E0306
join(sep)Stringscalars only; containers are E0307
sort()List
reverse()List
sum()Int
min() / max()element
flatten()Listone level: [[1], [2, 3]] → [1, 2, 3]
to_object()Objectbuilds an object from { key, value } pairs; a repeated key is E0323
reduce(init) (acc, e) -> … endAnyleft fold; the initial value is required
any (e, i) -> Bool endBoolsome element matches; false for an empty list
all (e, i) -> Bool endBoolevery element matches; true for an empty list
find(default) (e, i) -> Bool endAnyfirst match, or the default
index_of(value, default)Intindex of the first equal element, or the default; the default must be an Int
group_by (e, i) -> key endObjectelements by a String key, keys in the order they first appear (D39)
sort_by (e, i) -> key endListascending and stable; keys follow sort() (D39)
flat_map (e, i) -> [...] endListmap, then the lists joined one level; a non-List from the lambda is E0306 (D39)
zip(other)List{ left, right } pairs; lists of different lengths are E0327 (D39)
slice(start, end)Listend exclusive: [1, 2, 3].slice(1, 3) → [2, 3]
to_json() / to_yaml() / to_toml()Stringthe compact form, for embedding; to_toml on a list is E0603
to_json_pretty() / to_yaml_pretty() / to_toml_pretty()Stringthe form a person reads, for a file that gets diffed

Object

MethodResultNotes
len()Int
keys() / values()Listdeclaration order
contains(key)Bool
get(key, default)valuea miss gives the default
entries()List{ key, value } objects in declaration order; the inverse of to_object()
merge(other)Objectthe right side’s keys win
to_json() / to_yaml() / to_toml()Stringthe compact form, for embedding
to_json_pretty() / to_yaml_pretty() / to_toml_pretty()Stringthe form a person reads, for a file that gets diffed

Operators that build values

Most operators are numeric. Two are not, and they are the only ones whose result can be larger than what went in.

ExpressionResultNotes
"a" + "b"Stringjoins two strings; interpolation "#{a}#{b}" does the same
[1] + [2, 3]Listjoins two lists; the operands are unchanged

+ never converts. "port " + 8080 is E0306, and the diagnostic names the side to call .to_str() on. Joining is bounded at 1,000,000 list elements and 16 MiB of string, reported as E0322.

Global functions

FunctionRight requiredBehaviour
range(n)—[0, 1, … n-1]
env(name, default)--allow-envan environment variable; absent → the default
read_file(path)--allow-readthe file’s contents as a string; a relative path is read from the manifest’s folder (D37)
fail(message)—stops with E0531 and your message

A method call takes precedence over an object field of the same name that happens to hold a function. A package’s exported functions are called with the same syntax: pkg.fn(...).

Error and warning codes

Every Aura diagnostic carries a stable code, which makes it easy to grep for in CI logs and to refer to in discussion. E is an error, W a warning — and under --strict warnings block too.

Host (E00xx)

Raised at the boundary between a manifest and the application that embeds it. E0001-E0006 carry no source position, because nothing was parsed yet; E0007-E0009 point at the call in the script (D35).

CodeMeaningHow to fix it
E0001the host could not open a file, or aura.lock is unreadablethe message carries the underlying I/O error
E0002the script exports nothing by that namecheck the name; only : properties and pub def are exports
E0003the export is not a functionbind it with get, not function or call
E0004the function takes a different number of argumentschecked when the function is bound, before any call
E0005an argument has no exact Aura valuean integer past i64, a NaN or an infinity, a byte string; refused rather than coerced
E0006the result does not match the host’s typethe message names the path inside the value where it did not
E0007the script imports or calls a host function the host did not providea host: module is provided by the application at run time, so aura eval stops here; aura check lists what the script needs
E0008a host function returned an errorthe message is the application’s; the script cannot recover from it
E0009a host function was called with the wrong number of arguments (at load) or the wrong types (at the call), or returned a value Aura cannot holdthe message names the function
E0010a script was called from inside one of its own host functionsnot allowed (D35): return a value to the script instead
E0011a reload would change what the script exports: an export removed, or its kind or number of arguments changedthe host may have bound it, so the old version keeps serving (D36); restart to change the exports

Lexing (E01xx)

CodeMeaningHow to fix it
E0101no digit after the decimal point (12.)write 12.0 or 12
E0102a string left unclosed at end of lineAura strings are single-line; close the "
E0103an integer literal does not fit in i64
E0104a registry import without a versionimport a/b@v1 as x — the version is mandatory (D8)
E0105unexpected character
E0106an unclosed #{ interpolation
E0107an unterminated block stringclose it with end at the opener’s indentation (D16)
E0108a digit separator is misplaced_ groups by threes away from the point: 10_000_000, 0.000_001 — never 100_, 1__0 or 2_3_7_1_9_3_3

Syntax (E02xx)

CodeMeaningHow to fix it
E0200a specific token was expectedthe message names which
E0201a v1.1 inline block (metrics port: 9090)use an object block: metrics: … end (D3)
E0203a missing end or ]
E0204an expression was expected
E0205statements are separated by line breaks
E0206pub not immediately before def, type or enumproperties export themselves; = is always private
E0207a cond with no else armadd else -> ...: every branch must produce a value (D14)
E0208nesting too deepthe recursion limit that makes deeply-nested input an error rather than a stack overflow; a chain like a + b + c counts its length (D42)
E0211an enum member is not a quoted stringmembers are listed as strings, one per line (D18)
E0212a duplicate enum memberlist each member once
E0213an enum with no membersan empty set cannot be satisfied
E0214a value is computed and thrown awaya bare expression outside the end of a function goes nowhere; write key:, name = or assert (D40)
E0215a body has properties and also ends in a valuea function returns one or the other; make the value a property, or drop the properties (D40)

Evaluation (E03xx)

CodeMeaningHow to fix it
E0301assigned twice — values are immutableuse a new name, or a nested scope
E0302shadowing an outer variable without the markershadow x = ... (D7)
W0303shadow shadows nothingdrop the shadow
E0304integer arithmetic overfloweduse Float, or reconsider the magnitudes
E0305division by zero
E0306an operand of the wrong typeconditions must be Bool; there is no truthiness
E0307a non-scalar in interpolation or joinserialise it explicitly with .to_json()
E0308unknown object fielda typo? use .get(key, default) for optional ones
E0309unknown methodsee the method reference; a package’s function fields are called the same way
E0310missing capabilitythe error names the flag you need (D1)
E0311a path outside the directories allowed by --allow-read
E0312fewer arguments passed than the function has parameters
E0313a file read failed (I/O)
E0314malformed TOML, JSON or YAML while parsing
E0315a method that needs a lambda (map, filter) did not get one
E0316a malformed expression inside #{...}
E0317a list index out of range, or first() / last() on an empty listuse .get(i, default)
E0318obj["key"] — brackets are not allowed on objectsuse the dotted form: obj."key" (D11)
E0319a malformed durationthe format is "1h30m", units d/h/m/s
E0320a malformed dateRFC 3339: "2026-07-18T12:00:00Z"
E0321malformed base64base64_decode() on input that is not base64
E0322a concatenated list or string exceeds its size limit+ doubles: 1M elements / 16 MiB is the ceiling
E0323to_object() got the same key twicea silent overwrite would depend on element order; deduplicate first
E0324null rendered as textinterpolation and to_str() must agree; say what the empty case prints (D31)
E0325an evaluation ran out of its step budget--max-steps raises it, --stats shows what is spent (D34); in Script, the budget is per call (D33)
E0326an evaluation ran out of its memory budget--max-memory raises it; every value and frame created counts (D34)
E0327zip() got lists of different lengthsa dropped tail would drop configuration silently; cut the longer one with slice first (D39)
E0328a value nested more than 256 lists and objects deepalmost always a loop that wraps its result each turn; build the levels side by side instead (D43)
E0398internal: a closure’s environment was droppeda bug in Aura — please report it with the manifest
E0399evaluation nested more than 256 levelsalmost always a recursive function without the case that stops it; map, filter and reduce walk a list without nesting (D42)
E0533now() and timestamp() do not existthe host passes time in: env("BUILD_TIME", ...) (D13)

Modules and packages (E04xx)

CodeMeaningHow to fix it
E0401a cyclic import (the chain is in the message)break the cycle: move shared code into a third module
E0402a package’s integrity hash does not match aura.lockthe package’s contents changed — find out why
E0403--frozen: no entry in aura.lockrun aura add locally and commit the lock
E0404module not found, or does not resolvecheck the path, the version, --registry-dir
E0410an import the interpreter was handed without the module loaderonly reachable when embedding the evaluator directly; use facade::eval_file/eval_source, which load modules

Analysis and validation (E05xx / W05xx)

CodeMeaningHow to fix it
W0501an unused variabledead code — delete it
W0502an unused import
W0503an unused function or typeif it is a package’s API, mark it pub
E0504an undeclared variablea declaration must precede its use
E0505env() or read_file() under --hermeticpass the value in from the host, or drop the flag and grant the capability
E0511a field the schema requires is missing
E0512a field’s type does not match the schemaInt and Float are different types
E0513an extra field (--strict only)remove the field, or add it to the schema
E0514a value outside the enumthe hint suggests the nearest member and lists the set (D18)
E0515a schema invariant does not hold for this instancethe schema’s own assert; the message shown is the one it carries
E0516a default its own field can never acceptchecked where the schema is declared, not where it is used
W0512an effectful call in an imported moduleimports have no I/O rights; reconsider the package’s design
W0513a nullable field is filled from get(key) or env(name) with no fallbackabsence becomes null silently; name the fallback, or drop the ?
E0530an assert failedthe message is your own
E0531fail(...) was called

Serialisation (E06xx)

CodeMeaningHow to fix it
E0601a function or schema inside the exported treethe key path is in the message; at the top level pub items are excluded automatically
E0602a non-finite Float (NaN or Inf)
E0603a format limitation (TOML has no null; its root must be an object)
E0604two keys flatten to the same json-flat keya key containing a dot is indistinguishable from a nested path; use --format json, or rename the key