Skip to content

Durable providers

A durable provider connects Long Tail to the engine that runs your workflows. HotMesh is built in and needs none. @hotmeshio/long-tail-temporal and @hotmeshio/long-tail-dbos ship providers for Temporal and DBOS, and the same interface fits another engine.

long-tail.ts
import { start } from '@hotmeshio/long-tail';
import { temporalProvider } from '@hotmeshio/long-tail-temporal/provider';
await start({
database: { connectionString: process.env.DATABASE_URL },
durable: {
provider: temporalProvider({ address: 'temporal:7233', namespace: 'default' }),
consoleUrl: 'https://temporal.acme.dev/namespaces/default/workflows/{workflowId}',
relay: { sweepMs: 30_000, reconcileMs: 900_000 },
},
});
KeyMeaning
providerThe engine running your workflows. Leave durable out for HotMesh
consoleUrlA link to the engine’s own console for one workflow. {workflowId} is the placeholder
relay.sweepMsThe recovery sweep interval. Default 30 seconds
relay.leaseMsThe delivery lease per wait. Default 30 seconds
relay.batchWaits claimed per pass. Default 50
relay.reconcileMsHow often Long Tail closes escalations whose workflow has ended. Default 15 minutes; 0 turns it off

A deployment with a provider runs that engine’s workflows. start() refuses settings that start HotMesh workflows alongside it: workers, examples and escalation.strategy: 'mcp'.

A workflow opens a wait, and Long Tail creates the escalation. When the escalation is resolved, cancelled or expired, Long Tail delivers one outcome:

interface LTWaitOutcome {
waitKey: string;
escalationId: string;
status: 'resolved' | 'expired' | 'cancelled';
data?: Record<string, unknown>; // the resolver's payload, when resolved
resolvedBy?: string;
resolvedAt?: string;
}

The provider packages’ conditional() returns data when resolved, and null when expired or cancelled.

A wait is identified by provider, workflow id, run scope and wait key, so opening the same wait twice returns the same escalation. Keys are unique per wait within one run; a loop adds its round to the key.

Each provider package’s conditional() calls this endpoint from an activity or step the engine checkpoints. It authenticates as a bot (service account) with write access to the target role.

POST /api/escalations/waits
{
"workflowId": "order-123",
"runScope": "<engine run id, optional>",
"workflowType": "reviewOrder",
"waitKey": "review-order-123",
"role": "reviewer",
"type": "order-review",
"description": "Review order 123",
"priority": 2,
"metadata": { "orderId": "order-123" },
"data": { "risk": 0.42 },
"expiresAt": "2026-11-13T12:00:00.000Z"
}
StatusMeaning
201Created: { escalationId, status: 'pending', created: true }
200The wait already exists. The existing escalation comes back with created: false
400The body is invalid, or metadata does not match the role’s metadata schema
403The caller lacks write access to the role
501No provider is configured. HotMesh workflows call conditional() directly

metadata holds the facets and is validated against the role. data is shown to the resolver. expiresAt is the SLA.

The relay runs in every Long Tail container:

  • It runs when an escalation is resolved or cancelled, and on a sweep (relay.sweepMs) that also runs at startup.
  • It leases each undelivered terminal wait, so concurrent containers deliver it once.
  • The provider’s deliver returns delivered, or gone when the workflow no longer exists. A thrown error is retried with capped exponential backoff, and the error is kept on the wait.
  • Every resolve surface delivers the same way: the dashboard, the HTTP API, MCP tools, bulk resolve, resolve by metadata and scan rules.
  • The sweep expires pending waits whose expiresAt has passed and delivers expired.
  • Reconcile asks the provider about each workflow with a pending escalation, and cancels the escalation when the workflow has stopped running.
  • Opening a wait again after its outcome was delivered clears the delivery, so the outcome is sent again. This covers an engine that re-runs the wait without its checkpoint.
  • After a delivery, the relay checks the workflow with the provider’s describe at growing intervals for about 25 seconds. When the engine reports it closed, Long Tail publishes workflow.completed or workflow.failed, so the dashboard’s workflow pages refresh.

Waits are stored in lt_durable_waits, one row per wait, keyed by the escalation id.

The escalation, role, identity and admin surfaces work the same on every engine. These routes read from the provider:

EndpointWith a provider
GET /api/workflow-states/jobsThe executions list from the provider, in the shape HotMesh returns. search is a workflow id prefix, entity one workflow type, status one of running, completed, failed
GET /api/workflow-states/:id/executionThe execution timeline from the engine’s history
POST /api/workflows/:id/terminateStops the workflow, then cancels its pending escalations
GET /api/workflows/:id/describeThe workflow’s state, type and times

Routes that read or start HotMesh workflows answer 501 with the provider’s name, and their MCP tools stay unregistered:

  • /tasks, /controlplane, /dba, /pipelines, /yaml-workflows, /insight, /workflow-sets, /namespaces, /diagnostics and /config/maintenance
  • the other /workflows and /workflow-states routes
  • /escalations/bulk-triage

GET /api/settings reports the engine:

"engine": {
"provider": "temporal",
"native": false,
"capabilities": { "execution": true, "terminate": true, "list": true },
"consoleUrl": "https://temporal.acme.dev/namespaces/default/workflows/{workflowId}"
}

With list, the sidebar shows Executions under Workflows, and the page lists the engine’s workflows.

my-provider.ts
import type { LTDurableProvider } from '@hotmeshio/long-tail';
export const myProvider: LTDurableProvider = {
name: 'my-engine',
capabilities: { native: false, execution: true, terminate: true, list: true },
async deliver(ref, outcome) { /* signal ref.workflowId with outcome; return 'delivered' or 'gone' */ },
async describe(workflowId) { /* return { workflowId, state, providerState } */ },
async export(workflowId) { /* return an LTWorkflowExecution; summarizeExecution(events) fills summary */ },
async terminate(workflowId, reason) { /* stop the workflow */ },
async list(query) { /* one page, newest first, and the count of every match: { workflows, total } */ },
};

The workflow side does three things:

  1. Open the wait through POST /api/escalations/waits from an activity or step the engine checkpoints.
  2. Wait for the outcome on the key.
  3. Keep the first outcome per key as final.