TUCAN Orchestrator — Step Execution and Dynamic Workflow
Audiences: developer, internal
The orchestrator is TUCAN's planner + executor: it turns a classified intent into a task plan (L1), resolves session memory (L2), runs the plan as a Mastra workflow with per-step tool resolution (L3), renders the result (L4), and persists state to the session (L5). The L4/L5 stages are still their own
layers/Injectable services; the earlier standalonelayers/l1-intent-planner,l2-memory-resolver, andl3-orchestratordirectories were consolidated — L1 now lives inorchestrator/agents/meta-planner.agent.ts+steps/meta-plan.step.ts, and L2/L3 (memory resolution + layered plan execution) fold intoorchestrator/internal/layered-execution.tsand thesteps/factories.
Migration status (2026-07): this legacy TUCAN orchestrator runs alongside the Condor engine (condor-engine), whose own Mastra plan-compiler (
ai/condor/mastra/orchestrator/compiler.ts) is the next-generation replacement for this layered pipeline. Flows are ported per-intent, not all at once.
Business Purpose
A clinical assistant that can take real action — write prescriptions, order labs, draft referrals, schedule follow-ups, run a patient intake — needs more than "call one tool per turn." Real work is multi-step, stateful, and resumable:
- A patient-intake conversation iterates through 8-12 forms across potentially multiple sessions.
- A "schedule the follow-up and send the patient an SMS reminder" request is two tools + a callback if the SMS fails.
- A "diagnostic engine" turn picks ICD-10 candidates, fetches recent labs, runs a workflow that may suspend at each clinical decision.
The orchestrator gives Dudoxx this structure without bolting on a
workflow engine — Mastra's createWorkflow + createStep primitives
are wired through five layers (L1 plan → L5 persist) so every turn
gets the same lifecycle. The keystone workflow
orchestrated-dispatch ties it together and is the long-term
replacement for the legacy single-stage dispatcher funnel.
Audiences
- Investor: Multi-step, suspendable workflows are the moat for clinical AI. Competitors ship single-shot chat; Dudoxx ships resumable clinical conversations with audit-grade per-step persistence.
- Clinical buyer (doctor / nurse): Pause mid-intake, resume from a different device, never lose state. The system can dial back to "I need one more answer" and continue rather than starting over.
- Developer / partner: A new clinical flow is
createWorkflow(...) .createStep(...)with typedinputSchemaandoutputSchema. The orchestrator's L3 layer dispatches workflows by ID; the L1 planner picks which workflow to invoke for which intent (andintent-workflow-map.tsis the table). Adding a new flow does not require a dispatcher change. - Internal (ops / support): Every L5 persist writes a typed
slice into
ChatSession.metadata—outcomes,metricsTimeline,sidePanel,executionPlan,renderedSections. Replay is one SELECT.
Architecture
L1 — IntentPlannerAgent — layers/l1-intent-planner/intent-planner.agent.ts
| Detail |
|---|
LLM call returns Plan { tasks: TaskSpec[] } |
TaskSpec = { taskType, toolHint?, inputs, dependsOn? } |
TaskType = SEARCH | CREATE | UPDATE | NOTIFY | DELETE | UI_ACTION | WORKFLOW_RUN | ... |
respects descriptor.allowedTaskTypes + maxReplanAttempts |
L2 — MemoryResolverService — layers/l2-memory-resolver/memory-resolver.ts
| Detail |
|---|
| resolves recent turns / outcomes for each task |
| elision rules: trim per-turn history to fit context budget |
| feeds the task executor with "what the previous step did" |
L3 — WorkflowOrchestrator step + ToolFilter —
layers/l3-orchestrator/workflow-orchestrator.step.ts · layers/l3-orchestrator/tool-filter.ts
| Detail |
|---|
| for each task: resolve tool, run via task-executor |
task-executor/factory.ts builds the executor by complexity |
run-sub-workflow.ts dispatches a registered workflow |
run.watch(...) bridges Mastra step events → workflow:* SSE |
L4 — RenderOrchestratorService — layers/l4-writer/render-orchestrator.service.ts ·
layers/l4-writer/render-engine.ts
| Detail |
|---|
sanitize-narrative-html + render templates |
| structured outcomes → HTML/Markdown for the UI |
drives ChatMessage.renderedSections (L5 slice) |
L5 — PersistencyAnchorService — layers/l5-persistency/persistency-anchor.service.ts
| Detail |
|---|
| persist-then-publish discipline |
writes outcomes / metricsTimeline / sidePanel / renderedSections |
creates ChatMessage rows + TucanUsageRecord rows |
| then publishes SSE so consumers never see ephemeral state |
orchestrated-dispatch keystone workflow
The Mastra workflow that ties L1-L5 together is
buildOrchestratedDispatchWorkflow(deps) in
orchestrated-dispatch.workflow.ts (Mastra workflow id:
'orchestrated-dispatch'). It is built once per process from a
NestJS factory that resolves its deps (WorkflowService,
WorkflowEventsPublisher, etc.), composed of named steps:
resolveAndRunStep— for eachTaskSpecin the plan: resolve the tool, optionally short-circuit subsumed peers (composite-workflow revocation pass), execute viatask-executor/factory.ts.reportAndFinalizeStep— synthesize theDispatchReport, publish the L4 narrated output, finalize L5 persistence.
PlanWithResultsSchema carries the per-task outcomes through the
steps; OrchestratedDispatchOutputSchema is the workflow's terminal
output (plan + results + DispatchReport).
task-executor — the per-task dispatch core
Under orchestrator/steps/task-executor/ is a small factory pattern:
| File | Purpose |
|---|---|
factory.ts | Builds the executor by task complexity (run-by-complexity.ts picks the strategy) |
dispatch-helpers.ts | Shared dispatch helpers |
run-sub-workflow.ts | When the task is WORKFLOW_RUN, dispatch a registered Mastra workflow by ID |
l4-render.ts | Per-task L4 render hook (feeds RenderOrchestrator) |
response-shapes.ts | Typed per-task response shapes |
types.ts | Shared task-executor types |
Registered workflows
15 standalone workflows live under tucan/workflows/:
agentic-diagnosis/— multi-agent diagnostic reasoningdeep-search/— Qdrant + document RAG searchdiagnosis-editor/— interactive diagnosis editordocument-generation/— letter / report generationfollow-up-scheduler/— multi-step follow-up bookingfree-chat/— generic chat fallbacklab-order-bundle/— multi-test order assemblymail-processing/— incoming email triagepatient-intake/— 8-12 step intake conversationpatient-search/— disambiguation with clarify-candidate suspendpre-visit-briefing/— pre-visit context assemblyprescribe-and-check/— prescription + drug interactionsymptom-analysis/— symptom → differentialtreatment-plan/— multi-step treatment-plan compositionvisit-clinical-capture/— chart-capture workflow
intent-workflow-map.ts maps intent → workflow ID for the planner's
fallback when no other workflow is named.
Tech Stack & Choices
| Component | Choice | Why |
|---|---|---|
| Workflow engine | Mastra createWorkflow + createStep (@mastra/core@1.13.0) | In-process, typed inputSchema / outputSchema, native suspend/resume. |
| Step schemas | Zod v4 throughout | Run-time validation + compile-time type derivation. |
| Layer split L1-L5 | Each layer is an @Injectable() (NestJS DI) | Replaceable independently; testable in isolation. |
| Plan typing | PlanSchema (schemas/plan.schema.ts) with TaskType enum + TaskSpec shape | Planner output is typed; downstream consumers do not need runtime type guards. |
TaskType enum | `SEARCH | CREATE |
| Composite workflow revocation | workflow-tool-capabilities.ts subsumes field | When a composite workflow is bound, peer tools it subsumes get short-circuited — prevents double-execution defect. |
| Memory elision | orchestrator/memory/elision-rules.ts | Per-rule history truncation so the planner sees relevant turns, not every turn. |
run.watch(...) bridge | Mastra run.watch events → SSE workflow:step:* | Frontend gets per-step "doing X" hints without polling. |
| Persist-then-publish | L5 writes Prisma first, SSE second | Consumer must never see a state that is not durable; tested by __tests__/persist-then-publish.spec.ts. |
renderedSections slice | L4 output stored on ChatMessage.renderedSections | UI hydrates rendered sections on reload without re-running L4. |
| Outcome shapes | schemas/outcome-shapes/ typed payloads (clinical / misc) | Per-task outcome is typed end-to-end — no string-shaped result bags. |
| Suspend / resume | Mastra suspend() inside a step → session.metadata.suspendedWorkflows[taskId] | HITL pauses survive process restart, browser reload, different device. |
Alternatives rejected:
- Temporal / Camunda / Step Functions — too heavyweight; would add a separate deploy artifact and a network hop. Mastra workflows are in-process.
- Hand-rolled state machine — Mastra's
createWorkflowalready handles step typing, suspend/resume, andrun.watchevent bridging. Re-implementing is yak shaving. - Single-stage dispatcher only — what we had; cannot model multi-step clinical conversations.
Data Flow
Business outcome: A doctor says "do the symptom analysis for Mrs. Garcia." The system runs a 5-step workflow: get patient context, ask for symptom severity (suspend), compute differential, propose tests, render the result. If the doctor closes the browser at step 2, she resumes on her phone and the conversation continues from step 2.
Technical mechanism:
- Dispatcher emits intent
clinical(orpatient-data). Intent descriptor allowsWORKFLOW_RUN.- L1 IntentPlannerAgent picks workflow ID
'symptom-analysis'viaintent-workflow-map.tsfallback or explicit hint.- L2 MemoryResolver hydrates patient context + the last 6 turns of relevant history.
- L3
resolveAndRunStepresolves the tool to theWORKFLOW_RUNexecutor →run-sub-workflow.tsdispatches Mastra workflow'symptom-analysis'.- Step 2 of
symptom-analysiscallssuspend({ stepId: 'severity', payload: { prompt: 'Rate severity 1-10' } }).- Outer orchestrator catches the suspend, writes
session.metadata.suspendedWorkflows[taskId] = { runId, stepId, workflowId, ... }, publishesworkflow:suspendedSSE event withtaskId.- Doctor closes the browser. Tomorrow on her phone:
POST /api/v1/agent/sessions/:id/messages→ChatOrchestratorService.resume(ddx-api/src/ai/tucan/orchestrator/chat-orchestrator.service.ts:152).- resume →
workflow.createRun({ runId })→run.resume({ step, resumeData: validatedDecision })(ddx-api/src/ai/tucan/orchestrator/chat-orchestrator.service.ts:267).- Workflow continues from step 3;
run.watchpublishesworkflow:step:*SSE events as the remaining steps execute.- L4 RenderOrchestrator narrates the differential. L5 persists
renderedSections, finalizes outcomes.
Implicated Code
ddx-api/src/ai/tucan/orchestrator/orchestrated-dispatch.workflow.ts:30— MastracreateStep / createWorkflowimports.ddx-api/src/ai/tucan/orchestrator/orchestrated-dispatch.workflow.ts:93—OrchestratedDispatchInputSchema = PlanInputSchema.ddx-api/src/ai/tucan/orchestrator/orchestrated-dispatch.workflow.ts:99—PlanWithResultsSchema.ddx-api/src/ai/tucan/orchestrator/orchestrated-dispatch.workflow.ts:109—OrchestratedDispatchOutputSchema.ddx-api/src/ai/tucan/orchestrator/orchestrated-dispatch.workflow.ts:168—buildOrchestratedDispatchWorkflow(deps).ddx-api/src/ai/tucan/orchestrator/orchestrated-dispatch.workflow.ts:227—resolveAndRunStep = createStep({ ... }).ddx-api/src/ai/tucan/orchestrator/orchestrated-dispatch.workflow.ts:250— composite-workflow revocation pass.ddx-api/src/ai/tucan/orchestrator/orchestrated-dispatch.workflow.ts:536—reportAndFinalizeStep.ddx-api/src/ai/tucan/orchestrator/orchestrated-dispatch.workflow.ts:582—workflow = createWorkflow({ id:'orchestrated-dispatch', ... }).ddx-api/src/ai/tucan/orchestrator/chat-orchestrator.service.ts:83— classChatOrchestratorService(entry; eventually replaces legacy dispatcher).ddx-api/src/ai/tucan/orchestrator/chat-orchestrator.service.ts:152—resume(ctx, decision)— Mastra workflow resume bridge.ddx-api/src/ai/tucan/orchestrator/chat-orchestrator.service.ts:267—run.resume({ step: entry.stepId, resumeData: validatedDecision }).ddx-api/src/ai/tucan/orchestrator/agents/meta-planner.agent.ts+orchestrator/steps/meta-plan.step.ts— L1 intent→task-plan meta-planner (replaced the retiredlayers/l1-intent-planner/;L1_INTENT_PLANNER_MODEL_KEYno longer exists as a named symbol).ddx-api/src/ai/tucan/orchestrator/internal/layered-execution.ts+orchestrator/internal/memory-sanitize.ts— L2 memory resolution + per-turn history sanitization (replacedlayers/l2-memory-resolver/).ddx-api/src/ai/tucan/orchestrator/steps/task-executor/+orchestrator/internal/layered-execution.ts— L3 layered plan execution (replacedlayers/l3-orchestrator/workflow-orchestrator.step.ts).ddx-api/src/ai/tucan/orchestrator/steps/tool-resolve.step.ts+orchestrator/tool-resolver/— per-task tool resolver (waslayers/l3-orchestrator/tool-filter.ts).ddx-api/src/ai/tucan/orchestrator/layers/l4-writer/render-orchestrator.service.ts:137— classRenderOrchestratorService.ddx-api/src/ai/tucan/orchestrator/layers/l4-writer/render-engine.ts— L4 render engine.ddx-api/src/ai/tucan/orchestrator/layers/l5-persistency/persistency-anchor.service.ts:129— classPersistencyAnchorService.ddx-api/src/ai/tucan/orchestrator/layers/l5-persistency/__tests__/persist-then-publish.spec.ts— persist-then-publish invariant test.ddx-api/src/ai/tucan/orchestrator/steps/task-executor/factory.ts— task executor factory.ddx-api/src/ai/tucan/orchestrator/steps/task-executor/run-sub-workflow.ts—WORKFLOW_RUNdispatcher.ddx-api/src/ai/tucan/orchestrator/steps/task-executor/run-by-complexity.ts— strategy by task complexity.ddx-api/src/ai/tucan/orchestrator/schemas/plan.schema.ts—PlanSchema+TaskSpec.ddx-api/src/ai/tucan/orchestrator/schemas/outcome-shapes/payloads-clinical.ts— typed clinical outcome payloads.ddx-api/src/ai/tucan/orchestrator/intent-workflow-map.ts— intent → workflow ID fallback table.ddx-api/src/ai/tucan/tools/registry/workflow-tool-capabilities.ts—subsumessource of truth for composite revocation.ddx-api/src/ai/tucan/workflows/symptom-analysis/— example registered workflow.ddx-api/src/ai/tucan/workflows/patient-intake/— multi-step intake with suspend per form.
Operational Notes
- Persist-then-publish. L5 writes Prisma before publishing the
SSE event; the test
l5-persistency/__tests__/persist-then-publish.spec.tsenforces it. Don't reorder. - Composite-workflow revocation is a defect guard. When a task
binds a composite workflow tool, the orchestrator runs a
revocation pass that short-circuits any peer task whose tool ID
is in the composite's
subsumesset (orchestrated-dispatch.workflow.ts:250). Adding a new composite without populatingsubsumescauses double-execution. - Sub-workflow
run.watchis the SSE pipe. Per-stepworkflow:step:*SSE events come fromrun.watch(...)insidetask-executor/run-sub-workflow.ts. If new workflows don't emit step events, the orchestrator did not subscriberun.watchfor them. - NestJS services cannot inject into Mastra steps. Workflows
use
fetch()calls to NestJS HTTP endpoints, not direct service injection ([[ddx-mastra-specialist_memory.md]]). Persistence side-effects happen via the calling NestJS service path after the workflow completes. renderedSectionson ChatMessage is L4's output. Reloading the conversation must NOT re-run L4 — hydration readsrenderedSectionsfrom the DB. If you see L4 cost on reload-only paths, the hydration is broken.- Suspend is per-task. Multi-suspend is not currently
supported — first-entry-wins on resume
(
chat-orchestrator.service.ts:197). run.watchevents must not race theagent:complete. When the workflow completes, the orchestrator'sreportAndFinalizeStepwaits forrun.watchflushes before emitting the terminaloktransition.L1_INTENT_PLANNER_MODEL_KEYis the RequestContext key used to override the L1 planner model per-tenant. Setting it on the request context allows partner clinics to run the planner on a different model than the dispatcher's LLM.free-chat,patient-search,patient-intakeare the only three workflow IDs allowed asfallbackWorkflowIdin intent descriptors. Adding a new fallback requires extending the union inIntentDescriptor.- Dynamic workflow creation is at register time, not run time.
Workflows are registered in
WorkflowModuleat boot. "Dynamic" here means the dispatcher picks which workflow ID to invoke per turn; not that the workflow definition is built at call time.
Related Topics
- tucan-assistant — orchestration entry that calls into the
orchestrated-dispatchworkflow. - tucan-dispatcher — legacy dispatcher pipeline; orchestrator
eventually replaces it (chat-mode flag flip per
plans/tucan-chat-mode/). - tucan-intent-filtering — supplies the descriptor read by L1 planner.
- ai-medical-tools — provides the tools each task executor resolves and runs.
- ai-chat-sessions — owns the L5-persisted metadata slices
(
outcomes,metricsTimeline,sidePanel,executionPlan,renderedSections). - tucan-consumption-pricing —
TucanUsageRecord1:1 with ChatMessage rows L5 persists; per-step cost rollup. - sse-event-engine (W1) — receives
workflow:step:*,workflow:suspended,workflow:completeevents. docs/claude-context/CLAUDE_MASTRA_CODEBASE_USAGE.md— MastracreateWorkflow / createStepreference (link, don't duplicate).