Your first conditional
Build reviewContent: a workflow that scores content with a model, approves confident results on its own, and parks everything else as a row a reviewer claims and resolves. By the end you will resolve the same wait from the dashboard, ltc, HTTP, the SDK and MCP, and handle all three outcomes: an answer, a timeout and a cancel.
This page assumes a project set up as in Install: @hotmeshio/long-tail installed and a Postgres database to point at.
What you are building
Section titled “What you are building”invoke reviewContent └─ analyzeContent (activity, checkpointed) ├─ confidence >= 0.85 → return approved └─ otherwise → conditional('review-<workflowId>', { role: 'reviewer', ... }) one row: searchable, claimable, deadlined, role-gated ├─ resolved → resume with { approved, notes } ├─ 72h pass → resume with false (row expired) └─ cancelled → resume with nullTwo calls carry the whole design. proxyActivities runs work your code controls. conditional waits on work it does not control and makes that wait a row.
src/ activities.ts analyzeContent forms/reviewer.ts role name, form schema, payload type, version workflows/review.ts reviewContent index.ts start()1. Write the activity
Section titled “1. Write the activity”An activity is a plain async function. Put every side effect here: model calls, HTTP, database reads.
export async function analyzeContent(content: string) { // Replace with your model or classifier. const flags: string[] = []; if (content.length < 10) flags.push('too_short'); if (/[<>]/.test(content)) flags.push('contains_html'); return { confidence: flags.length ? 0.4 : 0.95, flags };}2. Define the reviewer’s form
Section titled “2. Define the reviewer’s form”The form belongs to the role, not the workflow. Keep the role name, the schema, the payload type and the version together so they change together.
export const REVIEWER_ROLE = 'reviewer';export const REVIEWER_SCHEMA_VERSION = 1;
export interface ReviewDecisionV1 { approved: boolean; notes?: string;}
export const REVIEWER_FORM_V1 = { title: 'Content review', type: 'object', required: ['approved'], 'x-lt-help': '### {{metadata.contentId}}\n\nModel confidence: {{envelope.analysis.confidence}}\n\n{{envelope.content}}', properties: { approved: { type: 'boolean', title: 'Decision', 'x-lt-options': [ { value: true, label: 'Approve' }, { value: false, label: 'Reject' }, ], }, notes: { type: 'string', format: 'textarea', title: 'Notes', description: 'What you saw' }, },};The dashboard renders this schema as the resolve form. A boolean with x-lt-options renders as an Approve or Reject select, so both answers count as an answer for required. x-lt-help fills the side panel, reading the row’s metadata and envelope through {{domain.path}} tokens.
3. Write the workflow
Section titled “3. Write the workflow”import { Durable } from '@hotmeshio/hotmesh';import { conditional, type LTEnvelope } from '@hotmeshio/long-tail';
import * as activities from '../activities';import { REVIEWER_ROLE, REVIEWER_SCHEMA_VERSION, type ReviewDecisionV1 } from '../forms/reviewer';
const { analyzeContent } = Durable.workflow.proxyActivities<typeof activities>({ activities });
export async function reviewContent(envelope: LTEnvelope) { const { contentId, content } = envelope.data;
// 1. Score the content. The result is checkpointed. const analysis = await analyzeContent(content); if (analysis.confidence >= 0.85) { return { data: { contentId, approved: true, decidedBy: 'model', analysis } }; }
// 2. Park a reviewer row and wait for it. const { workflowId } = Durable.workflow.workflowInfo(); const decision = await conditional<ReviewDecisionV1>(`review-${workflowId}`, { role: REVIEWER_ROLE, type: 'content-review', priority: 2, description: `Confidence ${analysis.confidence}: review ${contentId}`, metadata: { contentId }, envelope: { content, analysis }, timeout: '72h', schemaVersion: REVIEWER_SCHEMA_VERSION, });
// 3. Branch on the outcome. if (decision === false) return { data: { contentId, approved: false, reason: 'timeout' } }; if (decision === null) return { data: { contentId, approved: false, reason: 'cancelled' } }; return { data: { contentId, approved: decision.approved, notes: decision.notes, decidedBy: 'reviewer' } };}What each piece does:
| Piece | Effect |
|---|---|
proxyActivities |
Wraps analyzeContent so its result is stored. After a crash the workflow replays and reuses the stored score instead of calling the model again. |
`review-${workflowId}` |
The signal id. It becomes the row’s signal_key, the resume key any caller can resolve by. Make it unique per wait. |
role |
The queue. Only members of reviewer see, claim and resolve the row. |
type, priority |
Routing and ordering. Lower priority numbers sort first. |
description |
The line people read in the queue. |
metadata |
GIN-indexed facets. Search, filter and claim by contentId. |
envelope |
Unindexed context for the form: the content and the analysis. |
timeout |
The deadline. When it passes first, the row becomes expired and the wait returns false. |
schemaVersion |
Pins this row to form version 1, whatever the role’s latest version becomes. |
The row, its metadata and its timer are written in the same commit that checkpoints the workflow, so the row exists from the first moment the workflow waits.
4. Register the role and the worker
Section titled “4. Register the role and the worker”import { start } from '@hotmeshio/long-tail';
import { reviewContent } from './workflows/review';import { REVIEWER_ROLE, REVIEWER_FORM_V1 } from './forms/reviewer';
async function main() { await start({ database: { connectionString: process.env.DATABASE_URL }, seed: { admin: { externalId: 'admin', password: process.env.ADMIN_PASSWORD } }, roles: [ { role: REVIEWER_ROLE, title: 'Content reviewer', form_schema: REVIEWER_FORM_V1, enforce_schema: true, reset: true, }, ], workers: [ { taskQueue: 'default', workflow: reviewContent, config: { invocable: true, description: 'Score content and send low scores to a reviewer' }, }, ], });}
main();| Setting | Why |
|---|---|
form_schema |
The first save of a role’s form is version 1, the version the workflow pins. |
enforce_schema: true |
Every resolve surface validates the payload against the pinned form and answers 422 on a violation. |
reset: true |
This role’s declaration is compared and applied on every boot, so a changed form becomes the next version. |
invocable: true |
The workflow can be started from the dashboard, ltc and POST /api/workflows/reviewContent/invoke. |
Start it:
DATABASE_URL=postgres://postgres:password@localhost:5432/mydb \ADMIN_PASSWORD='choose-a-password' \JWT_SECRET='a-long-random-string' \npx tsx src/index.ts5. Start a run that escalates
Section titled “5. Start a run that escalates”<b>hi</b> is short and contains markup, so it scores 0.4 and escalates.
Open http://localhost:3000, sign in as admin, open Invoke Tool, pick reviewContent and start it with:
{ "contentId": "post-1", "content": "<b>hi</b>" }export LT_PASSWORD='the password you chose'npx ltc login --server http://localhost:3000 --username admin --password "$LT_PASSWORD"npx ltc wf invoke reviewContent --data '{"contentId":"post-1","content":"<b>hi</b>"}'The command prints the workflow id, for example reviewContent-a1b2c3.
export LT_PASSWORD='the password you chose'TOKEN=$(curl -s -X POST http://localhost:3000/api/auth/login \ -H 'Content-Type: application/json' \ -d "{\"username\":\"admin\",\"password\":\"$LT_PASSWORD\"}" | jq -r .token)
curl -s -X POST http://localhost:3000/api/workflows/reviewContent/invoke \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"data":{"contentId":"post-1","content":"<b>hi</b>"}}'The 202 response carries workflowId.
The run calls the activity, gets 0.4 and parks. A reviewer row is now pending with metadata.contentId = "post-1" and signal_key = "review-<workflowId>".
6. Resolve it
Section titled “6. Resolve it”Pick any surface. Each one resolves the same row, and the workflow resumes in place with the payload.
- Open All Escalations and pick the
reviewerqueue. - Open the row. The side panel shows the help text with the content and the score.
- Select Claim. The form unlocks for the claim window.
- Choose Approve or Reject, add notes and submit.
npx ltc esc list --role reviewer --status pendingnpx ltc esc claim <escalation-id>npx ltc esc resolve <escalation-id> --data '{"approved":true,"notes":"Markup is harmless"}'A service that knows the workflow id resolves by signal key, straight from the id it already has:
curl -s -X POST http://localhost:3000/api/escalations/resolve-by-signal-key \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{ "signalKey": "review-reviewContent-a1b2c3", "resolverPayload": { "approved": true, "notes": "Markup is harmless" }, "metadata": { "outcome": "approved" } }'metadata is optional. It merges outcome facets into the row so the row records what was decided next to what was asked.
import { createClient } from '@hotmeshio/long-tail';
const lt = createClient({ auth: { userId: adminUserId } });
const result = await lt.escalations.resolveBySignalKey({ signalKey: `review-${workflowId}`, resolverPayload: { approved: true, notes: 'Markup is harmless' },});// result.status === 200, result.data.signaled === trueadminUserId is the adminUserId that start() returns when seed.admin is set. Any user or bot id with write access to the reviewer role works.
From an agent connected to the server’s /mcp endpoint, call the resolve_by_signal_key tool:
{ "signalKey": "review-reviewContent-a1b2c3", "resolverPayload": { "approved": true, "notes": "Markup is harmless" }}See MCP to connect an agent.
Because the role sets enforce_schema, a payload without approved is refused on every surface with the same 422 body, and nothing changes:
{ "error": "resolverPayload failed schema validation (1 violation)", "code": "schema_validation", "violations": [{ "field": "approved", "message": "Required" }], "role": "reviewer", "schemaVersion": 1}7. Read the result
Section titled “7. Read the result”npx ltc wf result <workflowId>{ "contentId": "post-1", "approved": true, "notes": "Markup is harmless", "decidedBy": "reviewer" }Over HTTP, GET /api/workflows/:workflowId/result returns the same result, or 202 while the run is still waiting.
The row is now resolved, with the payload stored as resolver_payload and the resolver in metadata.resolved_by.
Handle every outcome
Section titled “Handle every outcome”conditional returns T | false | null. Check false and null before reading the payload.
| Value | Cause | Row status |
|---|---|---|
T |
Someone resolved the row | resolved |
false |
timeout passed first |
expired. A late resolve answers 409 (already expired) |
null |
The row was cancelled, or the workflow was terminated | cancelled |
When you do not need to tell timeout from cancel apart, if (!decision) covers both.
Know who resolved it
Section titled “Know who resolved it”Interactive and webhook resolves carry the resolver’s identity under the reserved $resolution key. Declare it in the generic to read it:
import type { EscalationResolution } from '@hotmeshio/long-tail';
const decision = await conditional<ReviewDecisionV1 & { $resolution?: EscalationResolution }>( `review-${workflowId}`, { role: REVIEWER_ROLE, metadata: { contentId }, schemaVersion: REVIEWER_SCHEMA_VERSION },);
if (decision && decision.$resolution) { const { escalationId, resolvedBy, resolvedByEmail } = decision.$resolution;}$resolution rides the signal only. The stored resolver_payload holds the form payload as submitted.
Evolve the form
Section titled “Evolve the form”Add a field without stranding rows that are already open.
-
Add the field and bump the version and the type together.
src/forms/reviewer.ts export const REVIEWER_SCHEMA_VERSION = 2;export interface ReviewDecisionV2 {approved: boolean;category: 'spam' | 'abuse' | 'fine';notes?: string;}export const REVIEWER_FORM_V2 = {...REVIEWER_FORM_V1,required: ['approved', 'category'],properties: {...REVIEWER_FORM_V1.properties,category: { type: 'string', title: 'Category', enum: ['spam', 'abuse', 'fine'] },},}; -
Declare
REVIEWER_FORM_V2on the role and type the wait asconditional<ReviewDecisionV2>. -
Restart. Because the role sets
reset: true, the changed schema becomes version 2 in the role’s history.
New rows pin version 2. Rows parked under version 1 keep rendering and validating against version 1 until they are resolved. Inspect the history with ltc roles schema-versions reviewer.
