Skip to content

Lua handlers for Wraith twins

Lua handlers are the escape hatch. When a route’s behavior depends on input-dependent logic that anti-unification can’t recover from observations alone — a checkout total computed from line items, a status machine where the next state depends on the previous one — write a small Lua handler and wraith serve invokes it instead of rendering from the template.

The OrderLedger fixture twin ships seven Lua handlers and is the reference for the patterns below.

twins/<name>/lua/
├── handlers/ # one file per handler, named to match a route convention
│ ├── create_order.lua
│ ├── get_order.lua
│ └── list_orders.lua
└── lib/ # shared modules importable via wraith.import()
└── json.lua

Both directories are scanned at startup. Files with .lua or .luau extensions are loaded. Handlers are keyed by filename stem — create_order.lua becomes the handler named create_order.

You don’t have to re-synth to add or update a handler. Restart wraith serve and the new file is picked up.

When a route variant carries an explicit lua_hook field (rare; synth never sets this), that handler wins. Otherwise — the common case — handlers resolve by filename matching against the route’s inferred state op and entity type.

For routes with a state op:

State opFilenames that match
Createcreate_<entity>, add_<entity>, new_<entity>, post_<entity>
Readget_<entity>, read_<entity>, show_<entity>, fetch_<entity>
Updateupdate_<entity>, patch_<entity>, edit_<entity>, put_<entity>
Deletedelete_<entity>, remove_<entity>, destroy_<entity>
Listlist_<plural>, list_<singular>, index_<plural>, or bare <entity> (collection name)

For sub-resource routes (GET /orders/:id/invoice) the convention switches to HTTP method + last path segment:

MethodFilenames that match
GETget_<seg>, show_<seg>, read_<seg>, fetch_<seg>
POSTcreate_<seg>, post_<seg>, add_<seg>, new_<seg>
PUT, PATCHupdate_<seg>, patch_<seg>, edit_<seg>, put_<seg>
DELETEdelete_<seg>, remove_<seg>, destroy_<seg>

First match wins. Routes that don’t match any handler fall through to the synth template — silently, no warning. This is intentional: most routes don’t need a handler.

Every handler sees four global tables.

req.method -- "POST"
req.path -- "/v1/orders"
req.headers -- table with lowercase keys
req.query -- table of query string values
req.body -- raw request body as a string (or nil)
emit.status(201)
emit.header("content-type", "application/json")
emit.json(table) -- set body (JSON serializes from a Lua table)
emit.body(string_or_table) -- alias for emit.json()
emit.error(400, "invalid", "...") -- structured error envelope

Backed by the same per-namespace state store the synth dispatcher uses, so handlers and template-rendered routes can share entities.

state.get(entity_type, id) -- → table | nil
state.put(entity_type, id, data) -- upsert; returns true
state.delete(entity_type, id) -- → true
state.list(entity_type) -- → table of all entities of that type
state.query(entity_type, field, value) -- → array of entities where field == value
state.count(entity_type) -- → number
state.counter(name) -- atomic increment, returns new value
clock.now() -- current Unix timestamp (seconds)
clock.advance(60) -- advance the namespace clock; deterministic mode only

When [serve.clock] mode = "real" (the default), clock.now() reads the system clock. When mode = "deterministic", it reads from the seeded counter — same seed produces byte-identical timestamps across runs. See Configuration → [serve.clock].

Files under lua/lib/ are loadable via wraith.import:

local json = wraith.import("json")
local body = json.decode(req.body)

Each wraith.import call runs the library in an isolated scope and returns its exported module table.

twins/orderledger/lua/handlers/create_order.lua, lightly edited:

-- POST /orders — create order with computed total.
local json = wraith.import("json")
local body = json.decode(req.body)
if not body or not body.customer_id then
emit.status(400)
emit.json({ error = { code = "invalid_request", message = "customer_id is required" } })
return
end
-- Reference an existing entity.
local customer = state.get("customers", body.customer_id)
if not customer then
emit.status(400)
emit.json({ error = { code = "invalid_customer", customer_id = body.customer_id } })
return
end
-- Compute the total from request items.
local items = body.items or {}
local total = 0
for _, item in ipairs(items) do
total = total + (item.price or 0) * (item.qty or 1)
end
total = math.floor(total * 100 + 0.5) / 100 -- round to 2 decimals
-- Generate an ID via the namespace counter.
local seq = state.counter("order_seq")
local oid = string.format("ord_%08x", seq)
local now = clock.now()
local order = {
id = oid,
customer_id = body.customer_id,
items = items,
item_count = #items,
total = total,
status = "draft",
created_at = now,
updated_at = now,
}
state.put("orders", oid, order)
emit.status(201)
emit.json(order)

Patterns this demonstrates:

  • Parse the request body and validate.
  • Reference an entity that was seeded (or created earlier in the session) via state.get.
  • Generate a deterministic ID via the namespace counter.
  • Read the deterministic clock for created_at / updated_at.
  • Persist the new entity via state.put.
  • Set status and body via emit.

Handlers run in mlua’s Luau sandbox with sandbox(true) enabled. The following are NOT available:

  • io, os, debug libraries (no filesystem, no system access, no introspection).
  • load, loadstring (no dynamic compilation).
  • getmetatable, setmetatable, rawget, rawset (no protocol escape).
  • Network or FFI access.

What IS available:

  • math, string, table, type, ipairs, pairs, next, select, tonumber, tostring.
  • error, pcall, xpcall for controlled error handling.
  • The four globals above (req, emit, state, clock).
  • wraith.import(name) for loading shared libraries.

Per-invocation limits:

  • 100 ms wall-clock timeout. Long-running handlers get killed.
  • 1 MB memory limit.
  • 100,000 Luau instructions. CPU budget.

Exceeding any limit raises a handler error and falls into the configured on_error policy.

[serve.lua] on_error in wraith.toml controls what happens when a handler raises:

[serve.lua]
on_error = "fail" # default

In fail mode, an uncaught handler error returns HTTP 500 with a structured envelope:

{
"error": {
"type": "internal_error",
"message": "Handler execution failed: ...",
"handler": "create_order"
}
}

In fallback mode (legacy), the error is logged and dispatch falls through to the synth template. This hides bugs and is opt-in only for compatibility with twins authored before on_error shipped.

  • Echo a field from the request into the response. Synth’s value-flow graph detects request echoes algorithmically — let it. Writing a Lua handler for this is more brittle than the inferred template.
  • Return a different shape based on a request field. Use request keying ([generate.request_keying]) — synth will synthesize one variant per bucket. Lua should be the second resort.
  • Generate a constant body that varies only by hole. Synth’s hole classifier already covers this.

Lua is for behavior synth can’t infer: computed totals, multi-step state transitions, cross-entity joins, things where the response depends on a small program. If you’re writing the same handler-shaped code in your test fixtures, that’s a strong signal it belongs as a Lua handler in the twin instead.