Lua DSL Reference
This page is the authoritative reference for the sigil Lua API. Scenarios are Lua 5.4 files executed in an mlua sandbox.
Pre-injected globals
Section titled “Pre-injected globals”| Name | Type | Purpose |
|---|---|---|
sigil | table | The main API surface. |
expect | function | Power assertion with source-rewriting. |
invariant | function | Property-based testing. |
Do not require('sigil') — it is a global, and requiring it is a hard error.
sigil.get/post/put/patch/delete(path, [body], [opts])
Section titled “sigil.get/post/put/patch/delete(path, [body], [opts])”Requires capability: http
local res = sigil.post("/api/login", { email = "alice@example.com", password = "hunter2",}, { headers = { ["X-Request-Id"] = "req-123" }, timeout_ms = 5000,})Returns:
{ status = 200, headers = { ["content-type"] = "application/json", ... }, body = "raw byte string body", json = { ... }, -- parsed if content-type is application/json elapsed_ms = 47,}body preserves the exact wire bytes, including embedded NULs and invalid
UTF-8. When Sigil records internal JSON trace evidence, valid UTF-8 remains a
string and binary data becomes an explicit bounded base64-preview object
with byte count, BLAKE3 digest, and a preview-truncation flag. That projection
never changes the Lua value and is not exposed through lossy agent feedback.
sigil.exec(command, [opts])
Section titled “sigil.exec(command, [opts])”Requires capability: exec
local result = sigil.exec("redis-cli KEYS 'session:*'")expect(result.status == 0)Runs sh -c <command> on the host running sigil — not inside the deployed container — with the scenario’s environment plus SIGIL_ENV_URL, SIGIL_SCENARIO_ID, and SIGIL_SERVICE. command is a single shell string; there is no separate argument array. opts accepts cwd, env (a key = value table merged over the scenario env), and stdin.
Returns { status, stdout, stderr, stdout_truncated, stderr_truncated }. status
is the process exit code, or -1 if the process could not be spawned, output
collection failed or timed out, or the process ended without a numeric exit
code. stdin, stdout, and stderr are exact Lua byte strings, including NUL
and invalid UTF-8. Stdout and stderr are drained concurrently; each retains its
first 1 MiB and sets its *_truncated flag if more bytes were discarded.
Internal JSON evidence uses the same explicit base64-preview projection
described for HTTP bodies. Each call starts its direct command in an isolated
process group and terminates processes that remain in that group when the
command returns, times out, or Sigil receives Ctrl+C. This is lifecycle cleanup,
not a containment boundary: a command that deliberately creates a new session
can leave the group. Deny the exec capability for scenarios that are not
trusted with host shell access.
sigil.env(name)
Section titled “sigil.env(name)”Reads from a strict per-key allowlist, never the ambient process environment; a key that is not on the allowlist returns nil. Under sigil eval the allowlist is [scenario.env] in sigil.toml (literal values or { from = "PROCESS_VAR" } passthroughs, read from the control snapshot); under sigil run it is the repeatable --env KEY[=VALUE] flag. See Configuration → [scenario.env].
JSON decoding and structured data
Section titled “JSON decoding and structured data”sigil.json.decode(text, opts?) parses strict UTF-8 JSON without exec, a
plugin, or an additional capability. Its 2 MiB input ceiling may be lowered
with { max_bytes = 65536 }, never raised. Duplicate keys, fractional numbers,
out-of-range signed 64-bit integers, malformed input, and resource-limit
violations fail explicitly rather than returning a partial or lossy value.
Mappings and sequences are immutable host-owned values, not Lua tables. Empty
{} and [] remain distinct. Use ordinary field reads, positive one-based
sequence indices, #sequence, and ipairs(sequence). Missing members return
nil; present JSON null is sigil.data.null (sigil.json.null is the same
singleton). Assignment, pairs, rawget, and table.* are not structured-data
operations. HTTP res.json remains a Lua table: decode res.body explicitly
before using these helpers.
local data = sigil.json.decode(res.body)expect(sigil.data.kind(data.items) == "sequence")expect(sigil.data.has(data, "optional_value"))expect(data.optional_value == sigil.data.null)local items = sigil.data.select(data, '.items[*]')local active = sigil.data.where(items, function(item) return item.active == trueend)local names = sigil.data.select(sigil.data.sort(active, '.name'), '.name')expect(sigil.data.join(sigil.data.unique(names), ',') == 'alpha,beta')The bounded, format-neutral API is:
kind(value):missing,null,boolean,integer,string,sequence, ormapping.get(container, key_or_index)andhas(container, key_or_index): dynamic access without constructing selector text; present null counts as present.at(value, selector): at most one result;select(value_or_selection, selector): an immutable selection, including expansion.keys(mapping): a selection of keys sorted by UTF-8 bytes;count(sequence_or_selection): number of elements.where(selection, predicate)andall(selection, predicate): ordinary Lua predicates returning exactly one boolean;allon an empty selection is true.sort(selection, key_selector?): stable homogeneous boolean/integer/string sorting;unique(selection): scalar first-occurrence order;join(selection, separator): bounded scalar text, not JSON encoding.equal(left, right): bounded structural equality, including independently decoded containers.
Each name above lives under sigil.data. Selectors support only .field,
["quoted key"], [1], and [*]; a segment’s trailing ? turns that
segment’s missing member or kind mismatch into no result. There are no pipes,
predicates, interpolation, recursive descent, or jq expressions. Use Lua for
computation and get for dynamic keys.
The helpers reject arbitrary Lua tables, floats, and unrelated userdata. Depth, work, output, and shared structured-data memory ceilings fail closed. Decode/query traces retain counts, shape, limits, and digests rather than JSON, keys, selector text, or values; explicit scenario logging and attachments remain the author’s responsibility.
sigil.gen.*
Section titled “sigil.gen.*”Deterministic random-value generators. Every factory returns a lazy descriptor:
sigil.gen.int(min, max)sigil.gen.float(min, max)sigil.gen.bool()sigil.gen.uuid()sigil.gen.email({ domain = "example.com" })sigil.gen.string(min?, max?, charset?)sigil.gen.bytes(min, max?)sigil.gen.const(value)sigil.gen.one_of({ ... })
Pass descriptors directly to invariant.for_all. To materialize one value in
ordinary run() code, sample explicitly:
local request_id = sigil.gen.sample(sigil.gen.uuid())local email = sigil.gen.sample(sigil.gen.email({ domain = "example.com" }))sigil.gen.sample(generator) uses a domain-separated per-scenario counter.
Successive calls are deterministic for the recorded root seed without changing
the invariant case stream; UUIDs are distinct across sample indices, while
other generators may legitimately repeat. Descriptors also support :map(fn),
:filter(predicate, max_attempts?), and :list(min?, max?) before sampling or
use in an invariant.
Direct runners accept --seed <64-hex|auto> and default to auto. The chosen
32-byte root seed is printed in human output and recorded as top-level
rng_seed in JSON. Replay reuses the recorded root unless given an exact
64-hex override. math.random and math.randomseed are neutered — all
randomness must go through sigil.gen.*.
expect(expr, [message])
Section titled “expect(expr, [message])”Power assertion. Source-rewritten at parse time: captures both sides of ==/~=/</<=/>/>= and every step of dotted chain accesses. The optional second argument — a string, or { message = "…" } — is appended to the failure text as message: … and becomes the entry’s description in the report’s expects[] block when no --- doc comment precedes the call (a --- block wins when both exist). message is the only recognised key; anything else is ignored.
expect(res.json.user.email == "alice@example.com")-- on failure, Ariadne renders:-- res.json.user.email == "alice@example.com"-- │ │ │ │ │ │-- │ │ │ │ │ "alice@example.com"-- │ │ │ │ false-- │ │ │ "bob@example.com"-- │ │ { email = "bob@example.com" }-- │ { user = { email = "bob@example.com" } }-- { json = { user = { email = "bob@example.com" } } }
expect(res.status == 200, "login should succeed for a seeded user")sigil.log(message) and sigil.attach(name, value)
Section titled “sigil.log(message) and sigil.attach(name, value)”sigil.log records a scenario-local byte string; sigil.attach records a named
value (any JSON-representable Lua value) as evidence. Under sigil run and
sigil scenario run, JSON reports carry logs: [...] in call order and
attachments: { name = value } with last write wins on every scenario entry.
Neither reaches sigil eval’s lossy feedback or the PR comment.
Valid UTF-8 logs remain JSON strings. Invalid UTF-8 becomes
{encoding: "base64", byte_count, blake3, data_base64}; the base64 contains
the complete input, and the digest uses the blake3:<hex> form. A cumulative
1 MiB per-scenario byte budget fails the exceeding call explicitly without
recording or truncating that entry. Human sigil run streams log: lines to
stderr as they happen, using the same binary object and escaping control
characters in UTF-8 text. Human reports print attach: lines after the scenario.
sigil.log("seeded 3 users")sigil.attach("response_body", res.json)invariant(name, opts)
Section titled “invariant(name, opts)”Requires capability: property
invariant("reverse is self-inverse", { cases = 100, for_all = { s = sigil.gen.string(20) }, check = function(case) expect(reverse(reverse(case.s)) == case.s) end,})Options:
cases— number of cases to run (default 100).for_all— named generators.check(case)— function called per case.shrink— disable shrinking withshrink = false.
Seeds: BLAKE3(scenario_seed ‖ invariant_name ‖ case_index).
Direct operator runs retain counterexample.json for a minimized failure.
Evaluations and no-sink callers keep structured result evidence without writing
that sidecar into the caller’s checkout; holdout runs never write a plaintext
counterexample file. Explicit file writes are atomic, and persistence errors
are reported without replacing the original invariant failure.
sigil.judge(response, opts)
Section titled “sigil.judge(response, opts)”Requires capability: judge
The --- doc comment block immediately above the call is the rubric.
--- The response is a JSON object containing exactly: {id, email, role}.--- The role is one of: admin, user, guest.--- Timestamps are ISO-8601 with timezone.sigil.judge(res, { min_score = 0.8, rubric_id = "user-schema-v1", -- optional — for rubric analytics})Options:
min_score— required minimum score (0.0–1.0).rubric_id— optional tag for rubric analytics.provider— override the default[judge]provider for this call.
Returns { score, rationale, raw }. Fails the scenario if score < min_score.
sigil.intent(opts)
Section titled “sigil.intent(opts)”Requires capability: intent
The --- doc comment block above the call is the objective.
--- Complete the checkout flow for a Pro plan, annual billing.local result = sigil.intent({ capabilities = { "browser", "http" }, context = { test_card = "4242424242424242" }, capture = { order_id = "string: the order confirmation number", total_cents = "number: the final charged amount in cents", }, max_steps = 20,})expect(result.completed)expect(result.total_cents == 9900)Options:
capabilities— tools the intent can use.context— extra key-values injected into the LLM context.capture— fields to capture, withtype: descriptionformat.max_steps— step budget.
Returns { completed, summary, steps, ... } plus captured fields.
sigil.browser.*
Section titled “sigil.browser.*”Requires capability: browser
Uses Sigil’s in-process Chrome-for-Testing runner. Sessions isolate per scenario; the base URL is prepended to relative paths. See browser configuration.
| Method | Purpose |
|---|---|
sigil.browser.open(url) | Navigate. Returns nil. |
sigil.browser.click(selector) | Click. Returns nil. |
sigil.browser.fill(selector, value) | Fill input. Returns nil. |
sigil.browser.wait(opts) | Wait for {text}, {selector}, or {timeout_ms}. |
sigil.browser.text(selector) | Inner text (string). |
sigil.browser.html(selector) | Inner HTML (string). |
sigil.browser.title() | Page title (string). |
sigil.browser.url() | Current URL (string). |
sigil.browser.screenshot(path) | Write PNG. |
sigil.browser.eval(js) | Evaluate JS, return result. |
sigil.browser.cookies() | List cookies. |
sigil.browser.snapshot() | Accessibility tree. |
sigil.browser.visible(selector) | Bool: selector is visible. |
WebAssembly plugins
Section titled “WebAssembly plugins”A locked project plugin is loaded as a dotted Lua module:
local codec = require("wasm.codec")local echoed = codec["echo-u32"](42)expect(echoed == 42)The scenario’s strict policy declares the exact wasm.<name> capability, and
the project must contain a matching [plugins.require] entry plus valid
.sigil/sigil.plugins.lock. Installing bytes in the user cache is not enough.
Use sigil plugin install NAME[@VERSION] to acquire the verified bytes, then
sigil plugin add NAME[@VERSION] to adopt the dependency.
Plugin functions and WIT records, tuples, lists, options, results, flags, enums, variants, constructors, resources, and resource methods map to bounded Lua values. Each scenario/environment lane gets a fresh component instance and resource table. A plugin failure is typed infrastructure evidence and can never be scored as a behavioral pass.
The namespace is exact: require("wasm.codec") is valid;
require("wasm:codec") and require("codec.wasm") are not aliases. See
Using WebAssembly Plugins for installation, project locks,
CI sync, host grants, updates, and removal.
Scenario table
Section titled “Scenario table”Return from every scenario file:
return { title = "...", -- optional, human label priority = "P0"|"P1"|"P2", -- required tags = { "tag1", ... }, endpoints = { "GET /foo" }, budget = { max_seconds = 30 }, policy = { capabilities = { ... } }, -- required run = function() ... end, -- required}An unknown key in this table, or inside budget/policy, is a lint warning (W008) with a near-miss hint — timeout, timeout_ms, budget_ms and friends point at budget = { max_seconds = N }, since the wall-time budget is the only time bound and those spellings were silently ignored before 0.31. sigil run prints W008 to stderr.
Errors
Section titled “Errors”require('sigil')→ error.require('wasm.NAME')without a project requirement, exact lock, orwasm.NAMEcapability → fail-closed plugin/capability error.- Using a capability not in
policy.capabilities→ scenario aborts with a capability-mismatch error and the scenario is marked failed. load,loadstring,loadfile→ unavailable.math.random,math.randomseed→ no-ops.os.execute,os.exit,io.*→ unavailable.