Skip to content

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.

the escalation row
{
  "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
Dashboard tour
A claimed escalation in the dashboard: the role's form, its instructions and the claim timer
A role's form schema, rendered as the resolver's UI.

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
HTTP API reference
bash
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
SDK reference
service.ts
ts
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
CLI guide
bash
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
MCP guide
bash
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
Machines guide
workflows/changeover.ts
ts
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
Scan codes guide
bash
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.