Skip to content

Triage and resolution strategies

Some escalations need a fix, not a judgment: content in the wrong language, an image upside down, a record that needs data from another system. The resolver knows what is wrong but cannot produce the corrected data. Let them flag the row with a hint and hand the fix to tools and an AI agent, then continue the workflow with the corrected result.

There are two ways to build this loop, one per escalation style.

Escalation style Who decides what a resolution does Where triage happens
conditional (resumes in place) Your workflow code, after the wait returns An activity or child workflow you call
Re-run path ({ type: 'escalation' } returns) A pluggable resolution strategy The built-in mcpTriage workflow, or your own

A conditional row resolves straight into the waiting workflow, so the triage branch is ordinary code. Add the flag and the hint to the role’s form, then act on them after the wait.

reviewer form (excerpt)
"needsRemediation": { "type": "boolean", "title": "Needs a fix before review" },
"hint": {
"type": "string",
"title": "What is wrong",
"x-lt-showIf": "resolver.needsRemediation",
"x-lt-options": [
{ "value": "wrong_language", "label": "Wrong language" },
{ "value": "unreadable", "label": "Unreadable" }
]
}
src/workflows/review.ts
type ReviewV2 = { approved?: boolean; needsRemediation?: boolean; hint?: 'wrong_language' | 'unreadable' };
let content = envelope.data.content;
for (let round = 1; round <= 3; round++) {
const decision = await conditional<ReviewV2>(`review-${workflowId}-${round}`, {
role: 'reviewer',
metadata: { contentId, round },
envelope: { content },
schemaVersion: 2,
});
if (!decision) return { data: { contentId, approved: false } };
if (!decision.needsRemediation) return { data: { contentId, approved: !!decision.approved } };
content = await remediate({ content, hint: decision.hint }); // your activity: a tool, a model, a service
}
return { data: { contentId, approved: false, reason: 'unresolved after remediation' } };

Each round parks a new row with its own signal id, so the history of every attempt stays on its own row. The remediation is a checkpointed activity like any other.

On the re-run path, resolving a row starts the workflow again. Before it does, the resolve route asks the registered strategy what to do with the resolution:

resolver payload ──► strategy.onResolution(context)
├─ { action: 'rerun' } → start the workflow again with envelope.resolver
└─ { action: 'triage' } → start a triage workflow with a triage envelope

The strategy sees the escalation record, the resolver payload and the original envelope.

Strategy Behavior
Default Always re-runs. Used when no strategy is set, or with strategy: 'default'
MCP Reads resolverPayload._lt.needsTriage. When set, routes to mcpTriage. Otherwise re-runs
Custom Your LTEscalationStrategy adapter decides
await start({
database: { connectionString: process.env.DATABASE_URL },
workers: [/* ... */],
escalation: { strategy: 'mcp' },
});

The _lt key in a resolver payload is reserved for Long Tail control flow. Everything outside it is the resolver’s own data.

Field Type Meaning
_lt.needsTriage boolean Route to triage instead of a re-run
_lt.hint string A remediation hint for the triage agent, such as 'wrong_language'
{
"resolverPayload": {
"_lt": { "needsTriage": true, "hint": "wrong_language" },
"notes": "The text is in German; the pipeline expects English"
}
}

When the MCP strategy routes a resolution to triage, the row is marked resolved and mcpTriageRouter starts. The router runs a compiled pipeline when one matches the problem, and otherwise hands off to mcpTriage:

  1. It reads the upstream runs and escalation history that share the row’s origin, to see what happened before.
  2. It loads the MCP tools scoped by the original workflow’s toolTags, plus the workflows, compiled and database tags.
  3. An LLM loop calls those tools with the hint as guidance, for example translate_content from the built-in translation server.
  4. It re-invokes the original workflow with the corrected data, which completes and signals its parent through the usual channels. When the agent cannot fix the problem, it escalates to an engineer, and their answer re-enters the loop with any new tools they installed.

The triage workflows (mcpTriageRouter, mcpTriageDeterministic, mcpTriage) register on the long-tail-system queue when ANTHROPIC_API_KEY or OPENAI_API_KEY is set.

A triage run that works can be compiled into a deterministic pipeline. The next time the same problem appears, the router finds the compiled pipeline and runs it without an LLM.

An admin can send several re-run rows to triage at once:

Terminal window
curl -X POST http://localhost:3000/api/escalations/bulk-triage \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{ "ids": ["<escalation-id>"], "hint": "wrong_language" }'
# { "triaged": 1, "workflows": ["triage-..."] }

Implement LTEscalationStrategy and return rerun or triage. A triage directive starts mcpTriageRouter with the envelope you return, so the simplest custom strategy decides when to triage and lets McpEscalationStrategy build that envelope:

src/strategy.ts
import {
McpEscalationStrategy,
type LTEscalationStrategy,
type ResolutionContext,
type ResolutionDirective,
} from '@hotmeshio/long-tail';
const mcp = new McpEscalationStrategy();
export class OutcomeStrategy implements LTEscalationStrategy {
async onResolution(context: ResolutionContext): Promise<ResolutionDirective> {
const { resolverPayload } = context;
if (resolverPayload.outcome === 'unreadable') {
return mcp.onResolution({
...context,
resolverPayload: { ...resolverPayload, _lt: { needsTriage: true, hint: 'unreadable' } },
});
}
return { action: 'rerun' };
}
}

Register it at startup, or through the registry:

await start({ database, workers, escalation: { adapter: new OutcomeStrategy() } });
import { escalationStrategyRegistry, McpEscalationStrategy } from '@hotmeshio/long-tail';
escalationStrategyRegistry.register(new McpEscalationStrategy());