Surfaces
One row. Every surface.
A workflow parks one escalation row. People, services, operators, agents, machines and stations all resolve that same row, under the same rules, and the workflow resumes the same way.
{
"id": "4b1f…",
"role": "reviewer",
"type": "content-review",
"status": "claimed",
"priority": 2,
"metadata": { "orderId": "A-1042", "contentId": "c-88" },
"signal_key": "review-wf-7c2e",
"assigned_to": "maya@acme.dev",
"assigned_until": "2026-10-23T18:30:00Z",
"created_at": "2026-10-23T17:52:11Z"
}Every surface reads and writes this row. Facets in metadata are GIN-indexed for search, claims and resolves.
Dashboard · Person
People resolve in a rendered form.
The role owns a versioned JSON Schema. The dashboard renders it as the resolver UI, validates the answer, and shows the claim timer, the facts and the history.
- Role-scoped lists, pinned views and wall-screen portals
- Claim, extend, release and resolve in one place
- Kiosk stations for shared tablets on the floor
HTTP · Service
Services resolve over REST.
Every dashboard action is an HTTP endpoint with the same RBAC. Resolve by id, by signal key, or by a domain key your system already knows.
- Bearer JWTs for people, API keys for bot accounts
- Resolve by metadata: no lookup, no read-then-write race
- One canonical 422 when a payload breaks the form
curl -X POST https://lt.acme.dev/api/escalations/$ID/claim \
-H "Authorization: Bearer $TOKEN" -d '{ "durationMinutes": 30 }'
curl -X POST https://lt.acme.dev/api/escalations/$ID/resolve \
-H "Authorization: Bearer $TOKEN" \
-d '{ "resolverPayload": { "decision": "approve" } }'SDK · Service
Node apps resolve in-process.
Embedded in NestJS, Express or any Node service, the SDK calls the same handlers the routes call. Every call returns an LTApiResult with the same status codes.
- No extra server or port when embedded
- Typed namespaces for escalations, workflows, roles and more
- Event callbacks with wildcards
const lt = createClient({ auth: { userId: serviceAccountId } });
await lt.escalations.claim({ id, durationMinutes: 30 });
await lt.escalations.resolve({
id,
resolverPayload: { decision: 'approve' },
metadata: { disposition: 'approved' },
});CLI · Operator
Operators resolve from the terminal.
`ltc` lists, claims and resolves from any shell or script, as the signed-in user. Output as tables, JSON or bare ids for piping.
- Find and act by metadata key and value
- Analytics: dwell and timelines from the command line
- Same login, same scope as the dashboard
ltc esc list --role reviewer --status pending
ltc esc claim $ID --duration 30
ltc esc resolve $ID --data '{"decision": "approve"}'
# or settle it by what you know about it
ltc esc resolve-by-meta orderId A-1042 --data '{"decision": "approve"}'MCP · Agent
Agents resolve over MCP, as you.
Claude, Cursor or any MCP client connects over OAuth and acts with exactly your roles. Finding, claiming and resolving work are tool calls. So is asking a person.
- escalate_and_wait turns a person into a tool
- Every MCP tool call runs as a durable activity
- Read-only deployments and read-safe workflows
claude mcp add --transport http long-tail https://lt.acme.dev/mcp
# then, inside the conversation:
# find_escalations { "role": "reviewer", "status": "pending" }
# claim_escalation { "id": "$ID" }
# resolve_escalation { "id": "$ID", "resolverPayload": { "decision": "approve" } }MQTT · Machine
Machines resolve over MQTT.
Each machine is a bot account on its station role. The gateway dispatches work, claims the row when the machine accepts, and resolves it when the units arrive.
- Handoff rows and command rows on the same queue as people
- Failures become decisions for a person, never silent retries
- Lot traceability by containment query
const reply = await conditional<Machines.CommandResolution>(`changeover-${orderId}`, {
...Machines.commandRow(Machines.commandTarget(line, 'bond-cell-1'), 'changeover', {
recipe: { id: 'RCP-BOND-B', version: '1' },
}),
timeout: '10m',
});
// reply: { accepted: true, machineId: 'bond-cell-1', op: 'changeover', status: 'accepted' }Scan · Station
Stations resolve with a scan.
A printed code finds the row it names, checks its queue and state, and runs a configured action. Anything that produces a string can drive it.
- version:category:target, parsed by a scheme
- Badges attribute the act to a person, never grant access
- A wrong-state scan still answers where the item is
curl -X POST https://lt.acme.dev/api/scan-codes/execute \
-H "Authorization: Bearer $TOKEN" \
-d '{ "code": "10:1:SN-123" }'
# { "outcome": "executed", ... } or "refused", "choices", "held", "not_primed"What stays the same
Six things every surface shares.
Validation
The role form schema runs on every surface. A violation returns the same 422 body everywhere.
Permission
Role membership with read and write scope, enforced in SQL. A caller can narrow a query, never widen it.
Claims
A time-boxed lock with a resolve guard. An expired claim returns the row to the queue.
Attribution
resolved_by lands on the row in the same statement that settles it, whoever the resolver was.
Events
Every crossing publishes system.escalation.{role}.{id}.{verb} for anything listening.
The wait
The workflow resumes with the same typed resolution. It never learns which surface answered.
Pick a surface. The row is waiting.
Start the stack locally and resolve your first escalation from the dashboard, then from the terminal.
