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.
Where handlers live
Section titled “Where handlers live”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.luaBoth 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.
Filename → route convention
Section titled “Filename → route convention”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 op | Filenames that match |
|---|---|
| Create | create_<entity>, add_<entity>, new_<entity>, post_<entity> |
| Read | get_<entity>, read_<entity>, show_<entity>, fetch_<entity> |
| Update | update_<entity>, patch_<entity>, edit_<entity>, put_<entity> |
| Delete | delete_<entity>, remove_<entity>, destroy_<entity> |
| List | list_<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:
| Method | Filenames that match |
|---|---|
| GET | get_<seg>, show_<seg>, read_<seg>, fetch_<seg> |
| POST | create_<seg>, post_<seg>, add_<seg>, new_<seg> |
| PUT, PATCH | update_<seg>, patch_<seg>, edit_<seg>, put_<seg> |
| DELETE | delete_<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.
The API surface
Section titled “The API surface”Every handler sees four global tables.
req — read-only request context
Section titled “req — read-only request context”req.method -- "POST"req.path -- "/v1/orders"req.headers -- table with lowercase keysreq.query -- table of query string valuesreq.body -- raw request body as a string (or nil)emit — write response
Section titled “emit — write response”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 envelopestate — per-session state store
Section titled “state — per-session state store”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 | nilstate.put(entity_type, id, data) -- upsert; returns truestate.delete(entity_type, id) -- → truestate.list(entity_type) -- → table of all entities of that typestate.query(entity_type, field, value) -- → array of entities where field == valuestate.count(entity_type) -- → numberstate.counter(name) -- atomic increment, returns new valueclock — deterministic time
Section titled “clock — deterministic time”clock.now() -- current Unix timestamp (seconds)clock.advance(60) -- advance the namespace clock; deterministic mode onlyWhen [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].
Importing libraries
Section titled “Importing libraries”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.
A minimal handler
Section titled “A minimal handler”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" } }) returnend
-- 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 } }) returnend
-- Compute the total from request items.local items = body.items or {}local total = 0for _, item in ipairs(items) do total = total + (item.price or 0) * (item.qty or 1)endtotal = 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.
The sandbox
Section titled “The sandbox”Handlers run in mlua’s Luau sandbox with sandbox(true) enabled. The following are NOT available:
io,os,debuglibraries (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,xpcallfor 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.
Error handling
Section titled “Error handling”[serve.lua] on_error in wraith.toml controls what happens when a handler raises:
[serve.lua]on_error = "fail" # defaultIn 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.
When NOT to use Lua
Section titled “When NOT to use Lua”- 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.