Skip to content

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.

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 null

Two 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()

An activity is a plain async function. Put every side effect here: model calls, HTTP, database reads.

src/activities.ts
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 };
}

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.

src/forms/reviewer.ts
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.

src/workflows/review.ts
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.

src/index.ts
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:

Terminal window
DATABASE_URL=postgres://postgres:password@localhost:5432/mydb \
ADMIN_PASSWORD='choose-a-password' \
JWT_SECRET='a-long-random-string' \
npx tsx src/index.ts

<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>" }

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>".

Pick any surface. Each one resolves the same row, and the workflow resumes in place with the payload.

  1. Open All Escalations and pick the reviewer queue.
  2. Open the row. The side panel shows the help text with the content and the score.
  3. Select Claim. The form unlocks for the claim window.
  4. Choose Approve or Reject, add notes and submit.

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
}
Terminal window
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.

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.

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.

Add a field without stranding rows that are already open.

  1. 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'] },
    },
    };
  2. Declare REVIEWER_FORM_V2 on the role and type the wait as conditional<ReviewDecisionV2>.

  3. 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.