One call, any engine
Every durable workflow eventually waits on something it does not control: a person, a machine, another system. Long Tail turns that wait into an escalation: routed to a role, claimable, deadlined, searchable, and answerable from the dashboard, HTTP, ltc or MCP. HotMesh is built in. Temporal and DBOS workflows plug in through a provider package and wait with the same call.
The engine owns time, retries and history. Long Tail owns everything between “a person needs to do this” and “here is the answer”.
The one call
Section titled “The one call”Pick your engine. The imports and the step wrapper change; the conditional() call and its payload are the same on all three.
import { Durable } from '@hotmeshio/hotmesh';import { conditional } from '@hotmeshio/long-tail';import * as activities from './activities';
type Decision = { approved: boolean };
const { scoreOrder, shipOrder } = Durable.workflow.proxyActivities<typeof activities>({ activities });
export async function reviewOrder(orderId: string) { await scoreOrder(orderId);
const decision = await conditional<Decision>(`review-${orderId}`, { role: 'reviewer', type: 'order-review', description: `Review order ${orderId}`, priority: 2, metadata: { orderId }, timeout: '48h', });
if (!decision) return 'expired or cancelled'; if (!decision.approved) return 'rejected'; return shipOrder(orderId);}import { proxyActivities } from '@temporalio/workflow';import { conditional } from '@hotmeshio/long-tail-temporal/workflow';import type * as activities from './activities';
type Decision = { approved: boolean };
const { scoreOrder, shipOrder } = proxyActivities<typeof activities>({ startToCloseTimeout: '1m' });
export async function reviewOrder(orderId: string) { await scoreOrder(orderId);
const decision = await conditional<Decision>(`review-${orderId}`, { role: 'reviewer', type: 'order-review', description: `Review order ${orderId}`, priority: 2, metadata: { orderId }, timeout: '48h', });
if (!decision) return 'expired or cancelled'; if (!decision.approved) return 'rejected'; return shipOrder(orderId);}import { DBOS } from '@dbos-inc/dbos-sdk';import { conditional } from '@hotmeshio/long-tail-dbos';import { scoreOrder, shipOrder } from './steps';
type Decision = { approved: boolean };
export const reviewOrder = DBOS.registerWorkflow(reviewOrderFn, { name: 'reviewOrder' });
async function reviewOrderFn(orderId: string) { await DBOS.runStep(() => scoreOrder(orderId), { name: 'score' });
const decision = await conditional<Decision>(`review-${orderId}`, { role: 'reviewer', type: 'order-review', description: `Review order ${orderId}`, priority: 2, metadata: { orderId }, timeout: '48h', });
if (!decision) return 'expired or cancelled'; if (!decision.approved) return 'rejected'; return DBOS.runStep(() => shipOrder(orderId), { name: 'ship' });}const answer = await conditional<T>(key, ask);keynames this wait inside the workflow. Opening the same key again returns the same escalation, so a retried step reuses it.askdescribes the escalation: who owns it, what kind of work it is, what people read, what they search by and when it expires.answeris the resolver’s payload, typed asT. When the escalation expires or someone cancels it, the workflow gets a falsy value and decides what that means.
The shared payload
Section titled “The shared payload”All three engines accept this object as it is:
{ "role": "reviewer", "type": "order-review", "description": "Review order 42", "priority": 2, "metadata": { "orderId": "42" }, "timeout": "48h"}| Field | Meaning |
|---|---|
role | The queue. Only members see, claim and resolve the escalation |
type | The kind of work, for routing and filtering |
description | The line people read in the queue |
priority | 1 (critical) to 4 (low) |
metadata | Indexed facets: search, filter, group and claim by orderId. Validated against the role’s metadata schema |
timeout | The SLA: '30s', '15m', '48h', '7d', '2w' or milliseconds. Long Tail expires the escalation when it passes |
What each side owns
Section titled “What each side owns”| The engine | Long Tail |
|---|---|
| Time, checkpoints and step retries | The escalation, role routing and RBAC |
| Recovery after a crash or a redeploy | Claims, forms, schema enforcement and facets |
| The workflow’s history | Priority, SLA expiry and cancellation |
| Your workflow and activity code | Delivery of exactly one outcome back to the workflow |
The wait, start to finish
Section titled “The wait, start to finish”The lifecycle is the same on every engine; only the last arrow’s transport changes.
workflow Long Tail people, services, agents──────── ───────── ────────────────────────conditional(key, ask) └─ open the wait ────────► one escalation in the role's queue ───────► see, claim, fill the form (idempotent on key) RBAC, facets, SLA timer resolve or cancel (or the SLA passes) escalation reaches a terminal state ◄─────── relay delivers the outcome once ◄─ answer, or a falsy value HotMesh: same commit · Temporal: signal · DBOS: messageworkflow continues ──────────► dashboard sees workflow.completed- Exactly one outcome. Resolved with the payload, expired, or cancelled. The relay delivers under a lease, retries with capped backoff, and records a workflow that has ended as gone.
- Any surface resolves it. The dashboard, HTTP,
ltc, MCP tools, bulk actions, scan rules and expiry all deliver the same way. The workflow sees one answer. - Restarts are routine. Temporal holds the signal and DBOS holds the message while a worker or app is down; the workflow reads it on recovery.
- The dashboard stays live. Resolving pushes
escalation.resolved, and the workflow’s completion pushesworkflow.completed, over WebSockets.
Where the engines differ
Section titled “Where the engines differ”| HotMesh | Temporal | DBOS | |
|---|---|---|---|
| Package | @hotmeshio/long-tail | @hotmeshio/long-tail-temporal | @hotmeshio/long-tail-dbos |
Import conditional from | @hotmeshio/long-tail | @hotmeshio/long-tail-temporal/workflow | @hotmeshio/long-tail-dbos |
| Context shown to the resolver | envelope | data | data |
| Expired | false | null | null |
| Cancelled | null | null | null |
| Form version pin | schemaVersion | the role’s latest form | the role’s latest form |
| How the answer arrives | in the same database commit that resolves the escalation | Temporal signal lt.outcome | DBOS message, idempotent on the escalation id |
| Infrastructure | Postgres | Postgres and a Temporal cluster | Postgres |
if (!decision) handles expiry and cancellation on every engine. To tell them apart on HotMesh, compare with false and null; see conditional.
HotMesh workflows also use batches and accumulators, triage, YAML pipelines and starting workflows from the dashboard, ltc and MCP.
The dashboard on each engine
Section titled “The dashboard on each engine”- Every engine: queues, claims, forms, escalation detail, facets and search, the pace board, analytics, roles, users, bots and MCP tools.
- Temporal and DBOS: an Executions list read from the engine (filter by workflow id prefix, type and state), and each workflow’s timeline built from the engine’s own history: steps, timers, child workflows and each wait from open to outcome. On Temporal, a link opens the workflow in the Temporal UI.
- HotMesh: also YAML pipelines, streams, the control plane, triage and AI query. With another engine these pages are hidden, and their API routes answer
501with the engine’s name.
GET /api/settings reports the engine, and the dashboard reads it to choose its pages. See Settings.
