Twin Lifecycle
This guide covers the full workflow for building and maintaining an API twin, from first recording to production-quality service.
Overview
Section titled “Overview”record → synth → check → generate → check → [lua handlers] → serve ↑ ↓ └────────── re-record if needed ─────┘- Record real API traffic
- Synthesize a deterministic model from recordings
- Check conformance (how well the model matches recordings)
- Generate LLM-assisted fixes for remaining divergences
- Lua handlers for routes the engine can’t fix algorithmically
- Serve the twin to your test suite
1. Record
Section titled “1. Record”Capture real API exchanges by proxying traffic through wraith:
wraith init stripe --base-url https://api.stripe.comwraith record stripe --port 8080Point your app at http://localhost:8080 and exercise the API. Each request/response pair is saved as a WREC file with secrets scrubbed.
Multiple sessions
Section titled “Multiple sessions”Record multiple sessions to give the synthesizer enough variation. Use /__wraith/new-session to force a session boundary without restarting the proxy:
wraith record stripe --port 8080 &
# Session 1: basic CRUDpython exercise.py --base-url http://localhost:8080 --sessions 10curl -X POST http://localhost:8080/__wraith/new-session
# Session 2: edge cases, errorspython exercise.py --base-url http://localhost:8080 --sessions 10 --errorscurl -X POST http://localhost:8080/__wraith/new-session
# Session 3: different data patternspython exercise.py --base-url http://localhost:8080 --sessions 10 --varied
kill %1The new-session endpoint closes all active sessions and finalizes their manifests. Subsequent exchanges start fresh sessions automatically.
Recording control plane
Section titled “Recording control plane”During recording, the proxy exposes control endpoints at /__wraith/*:
| Endpoint | Method | Description |
|---|---|---|
/__wraith/health | GET | Returns {"status": "recording"} |
/__wraith/ready | GET | Returns {"ready": true} |
/__wraith/info | GET | Active sessions, upstream URL, port |
/__wraith/new-session | POST | Force session boundary |
These are intercepted before proxying — they never reach the upstream API.
Session tagging
Section titled “Session tagging”Tag sessions for selective synthesis:
wraith record myapi --tag ci-test &python exercise-ci.py --base-url http://localhost:8080kill %1
# Later: build a model from only ci-test sessionswraith synth myapi --tag ci-test2. Synthesize
Section titled “2. Synthesize”Build a deterministic model from all recorded sessions:
wraith synth stripeThis produces twins/stripe/model/symbols.json — the WIR (Wraith Intermediate Representation) containing:
- Route patterns with parameterized paths
- Response templates with typed holes (dynamic fields)
- Variants per status code (200, 404, etc.)
- State operations (CRUD mapping)
- Field classifications (constant, generated, timestamp, echo)
- GraphQL operation routing (if detected)
synth stripe 25 routes 3544 symbols 22 state-opsGraphQL
Section titled “GraphQL”wraith detects GraphQL endpoints automatically (POST /graphql with query field). It splits the single route into per-operation variants using operationName or parsed query root field:
detected GraphQL endpoint -- splitting by operation route=POST /graphql exchanges=257GraphQL operation groups: AddComment, CloseIssue, CreateIssue, ... operations=16No configuration needed. Named queries, anonymous queries, mutations, and fragments all work.
3. Check conformance
Section titled “3. Check conformance”Measure how well the synthesized model matches the recordings:
wraith check stripe --in-memoryPASS stripe 10000/10000 sessions=3/3 divergences=0The score is in basis points (10000 = perfect). Divergences show where the model differs from recordings:
missing_field— recording has a field the model doesn’t produceextra_field— model produces a field the recording doesn’t havevalue_mismatch— field exists but value differsstatus_mismatch— HTTP status code differstype_mismatch— response body type differs
Understanding suppressions
Section titled “Understanding suppressions”The engine auto-suppresses comparison for fields it can’t reproduce deterministically (generated IDs, timestamps, list contents). Use --show-suppressed to see what’s being hidden:
wraith check stripe --in-memory --show-suppressedPASS stripe 10000/10000 sessions=3/3 divergences=0 (264 suppressed) Suppressed fields: body.id generated body.created timestamp body.updated timestamp body.data[*].id generated headers.x-request-id header_allowlist ...
To compare a suppressed field, add to wraith.toml: [diff.fields."<path>"] classify = "constant"Suppression reasons:
- generated — dynamic field (IDs, random values), compared by type only
- timestamp — time-like field, compared by type only
- header_allowlist — header not in the 3-header comparison list
- heuristic — field name pattern (
*_at,*_count) with matching types
Forcing comparison
Section titled “Forcing comparison”If a suppressed field matters to you, force comparison in wraith.toml:
[diff.fields]# These fields are computed -- compare exactly, don't suppress"total" = { classify = "constant" }"summary.total_value" = { classify = "constant" }"customer_name" = { classify = "constant" }Valid classifications: "generated", "timestamp", "constant", "echoed".
4. Generate (LLM-assisted repair)
Section titled “4. Generate (LLM-assisted repair)”Use the agentic route fixer to automatically fix divergences:
wraith generate stripe --provider ollama --model qwen3.5:9bThe generator:
- Picks the route with the most divergences
- Gives the LLM tools to inspect and edit the model
- Runs conformance checks after each edit
- Accepts improvements, rolls back regressions
- Repeats for the next route
Options:
wraith generate stripe --agentic # force agentic (tool-use) modewraith generate stripe --max-iterations 10 # fix up to 10 routeswraith generate stripe --provider openrouter --model anthropic/claude-sonnet # cloud modelThe regression guard ensures no fix makes things worse — both per-route and globally.
5. Lua handlers (last-mile escape hatch)
Section titled “5. Lua handlers (last-mile escape hatch)”The synth engine handles CRUD patterns deterministically. But some responses require logic the engine can’t express:
- Computed fields: totals, averages, aggregates derived from state
- Conditional shapes: fields present only under certain conditions
- Cross-entity joins: response includes data from a related entity
- State machine validation: only certain transitions are valid
- Custom formatting: non-standard date formats, encoded cursors
For these, write Lua handlers.
Directory layout
Section titled “Directory layout”twins/myapi/lua/├── handlers/ # Handler scripts (one per route)│ ├── create_order.lua│ ├── list_orders.lua│ └── get_invoice.lua└── lib/ # Shared libraries └── json.lua # JSON parser (or any shared utility)Writing a handler
Section titled “Writing a handler”-- twins/myapi/lua/handlers/create_order.lua-- Handles POST /orders -- computes total from line items
local json = wraith.import("json")local body = json.decode(req.body)
-- Compute total from itemslocal 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
-- Generate ID and storelocal seq = state.counter("order_seq")local oid = "ord_" .. string.format("%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)Available APIs
Section titled “Available APIs”Request context (read-only):
req.method -- "GET", "POST", etc.req.path -- "/orders/ord_123"req.headers -- {["content-type"] = "application/json", ...}req.query -- {["limit"] = "10", ...}req.body -- request body string, or nilState store (persistent across requests within a session):
state.get(type, id) -- read entity, returns table or nilstate.put(type, id, data) -- create or update entitystate.delete(type, id) -- delete entitystate.list(type) -- all entities of typestate.query(type, field, val) -- filter by field equalitystate.count(type) -- count entitiesstate.counter(name) -- atomic increment, returns new valueDeterministic clock:
clock.now() -- Unix timestamp (deterministic per session)clock.advance(secs) -- advance clock (default: 1 second)Response builder:
emit.status(201) -- HTTP status codeemit.header("x-custom", "value") -- add response headeremit.json({id = "123", name = "test"}) -- JSON body (from table)emit.body('{"raw": "json string"}') -- raw body stringemit.error(404, "not_found", "message") -- shorthand error responseModule system:
local json = wraith.import("json") -- load lua/lib/json.lualocal utils = wraith.import("utils") -- load lua/lib/utils.luaConnecting handlers to routes
Section titled “Connecting handlers to routes”Set lua_hook on the variant in symbols.json:
{ "method": "POST", "path_pattern": "/orders", "state_op": "create", "variants": [{ "status": 201, "body_template": {"id": "$hole_1", "total": "$hole_2", "...": "..."}, "lua_hook": "create_order" }]}The handler name matches the filename without extension: create_order -> handlers/create_order.lua.
If the Lua handler succeeds, its response is used directly. If it fails (error, timeout, missing file), the synth engine takes over as fallback — Lua never breaks the twin.
Resource limits
Section titled “Resource limits”Handlers run in a sandboxed Luau VM:
- Timeout: 100ms wall-clock
- Memory: 1MB per invocation
- Instructions: 100,000 VM instructions
- No filesystem, network, or
osaccess
Validation
Section titled “Validation”wraith doctor myapilua_handlers_valid INFO 7 Lua handler(s) compiled successfullyExample: computed fields vs synth engine
Section titled “Example: computed fields vs synth engine”Without Lua, the synth engine classifies computed fields as Generated and skips comparison:
wraith check orderledger --in-memoryPASS orderledger 10000/10000 sessions=2/2 divergences=185 body.total value_mismatch expected=134.34 actual=0 body.total value_mismatch expected=261.95 actual=0 body.customer_name value_mismatch expected="Alice" actual="Test 16" ...With Lua handlers computing the values correctly:
wraith check orderledger --in-memoryPASS orderledger 10000/10000 sessions=2/2 divergences=2The remaining 2 are a floating-point rounding edge case. The Lua handlers reduced divergences from 185 to 2 by computing totals, aggregates, and cross-entity joins that the deterministic engine can’t express.
6. Serve
Section titled “6. Serve”Start the twin server:
wraith serve stripeloaded Lua handlers count=3wraith server started twin=stripe addr=127.0.0.1:8081 fidelity=synthPoint your test suite at http://localhost:8081. The server:
- Matches requests to routes via the path trie
- Selects the best variant based on guards
- Runs Lua handler if
lua_hookis set on the variant - Otherwise renders from the synth template with hole replacement
- Manages per-session state (entities, counters, clock)
Fidelity modes
Section titled “Fidelity modes”| Mode | Description |
|---|---|
strict | Replay recorded responses verbatim (exact match) |
synth | Serve from synthesized model with state engine + Lua handlers (default) |
Iterating
Section titled “Iterating”The twin improves through iteration:
record more sessions → re-synth → check → generate → lua → checkEach cycle:
- More recordings give the synthesizer more variation to learn from
- Re-running
wraith synthrebuilds the model from scratch wraith generatefixes remaining divergences with LLM assistance- Lua handlers fill gaps the engine can’t close
--show-suppressedshows what the engine is hiding so you know where Lua is needed
Track progress with wraith check --in-memory — the session pass rate is the key metric.
Deciding between engine, generate, and Lua
Section titled “Deciding between engine, generate, and Lua”| Pattern | Who handles it | Example |
|---|---|---|
| CRUD responses with generated IDs | Synth engine | POST /users returns {id: "uuid", ...} |
| Timestamps, counters | Synth engine | created_at, request_count |
| Echoed request fields | Synth engine | Response contains request body values |
| Error variants by status | Synth engine | 400, 404, 422 responses |
| Template inconsistencies | Generate (LLM) | Wrong hole classification, missing optional field |
| Computed totals/aggregates | Lua | total = sum(items[].price * qty) |
| Conditional response shapes | Lua | shipping object only when status=shipped |
| Cross-entity joins | Lua | Invoice includes customer name from separate entity |
| State machine validation | Lua | Only draft->confirmed->shipped->delivered allowed |
| Non-JSON responses (HTML, binary) | Lua | Keycloak welcome page, binary downloads |