FlowAgent — Voice-Driven Clinical Workflow Scenarios
Audiences: doctor, developer, internal, investor
Author-once, run-anywhere clinical form-filling assistant: clinicians author a "scenario" (form sections, agent behaviour, voice tools) in ddx-web; the runtime compiles it into a Mastra
createWorkflow()chain where each section is a suspend/resume voice step — letting a patient or nurse fill an entire questionnaire by voice with deterministic completion detection, while ddx-api persists every transcript and checkpoint.
Business Purpose
Two clinical realities collide:
- Forms have to be filled — intake, anamnesis, consent, triage, follow-up — yet typing on a tablet kills consultation flow.
- LLM-only voice agents drift — they hallucinate "section complete" before all fields are filled, lock onto one question, or wander.
FlowAgent solves both by separating the scenario (clinic-authored structured form + agent persona + tool overlay) from the orchestrator (Mastra workflow that owns completion state). LLM does conversation; workflow owns advancement. This is what we sell to chronic-disease clinics (Mesalvo demo 2026-04-20), GP practices, and triage centres — a deterministic voice intake bot that the clinic can re-author for any speciality without touching code.
Audiences
- Investor: Replaces tablet kiosks. Authoring is a no-code-ish flow in ddx-web. Same engine drives Mesalvo demo + future Direct-Medical onboarding bots — one runtime, many SKUs.
- Doctor: Lets you reuse your existing paper or PDF form as a voice flow without IT involvement; you change a section's
agentBehavior.approachand the next session uses it (after re-seed). - Developer/partner: Mastra
createWorkflow()chain. Each section =createStep()withinputSchema/outputSchemaandsuspend()/resume(). Tools are scoped per step (max 7 — voice-essential set). - Internal (ops/support): All scenario state is versioned (
FlowAgentScenarioVersion); session state and transcript live inprisma-ai. Re-seed after every form/code change (see Pitfalls).
Architecture
ScenarioService (CRUD)
| Component |
|---|
scenario-data.mapper.ts → snapshot |
FlowAgentScenarioVersion (versioned) |
SessionService (lifecycle)
| Component |
|---|
runtime.service → snapshot |
checkpoint.service |
audit.service |
flowagent-events.publisher |
ddx-livekit-agents-ts/agents/flowagent (Node.js)
| Module | Role |
|---|---|
workflow-compiler.ts | → Mastra createWorkflow() |
workflow-steps.ts | greeting / section / done · suspend/resume per turn |
workflow-bridge.ts | STT → resume(transcript) · tool calls → fill_field… · TTS ← sanitizeForTTS(text) |
ddx-web FlowAgent UI
| Hook / component |
|---|
useFlowAgentSSE |
useFlowAgentLiveKit |
TUCANLiveKitRoom |
Two processes: NestJS owns authoring + persistence + SSE bridge; a separate Node process (ddx-livekit-agents-ts/agents/flowagent) hosts LiveKit + Mastra workflow + STT/TTS plumbing. (Text/agentic consumers use the AG-UI SSE bridge instead of the DataChannel — see flowagent-agui-bridge.md.)
Tech Stack & Choices
- Framework: Mastra v1.20.0 (
ddx-livekit-agents-ts/agents/flowagent/) — a DIFFERENT Mastra line from ddx-api's embedded@mastra/core(1.37.1 — verify live fromnode_modules/@mastra/core/package.json; the ddx-api CLAUDE.md "1.5.0"/prior "1.13.0" line is stale). Uses@mastra/core/workflowscreateWorkflow()+createStep(). - Persistence:
MASTRA_STORAGE_URL=file:./mastra-flowagent.db(LibSQL) for in-process memory; clinical state inprisma-aiPostgres schema (FlowAgentScenario / Version / Session, viaPrismaAiService). - Workflow shape: greeting step → section steps × N → completion step (
workflow-steps.ts:1-13). Sections suspend/resume per turn; when all fields handled, the step returns output and the chain auto-advances (nosuspend()call). - LLM:
createDudoxxMastraModel({ thinkingBudgetTokens: 0 })— voice latency budget (see voxial-llm.md). - Memory:
lastMessages: 20(config.ts), Mastra Memory backed by LibSQL. - Voice tool set: 7 essential per phase —
fill_field,skip_field,batch_fill,validate_field,update_field,get_status,clarify(NOT 26 —MEMORY_LIVEKIT.md). - Sanitisation: two-layer —
step-helpers.ts.sanitizeForTTS()+ the TTS-plugin prefilter (src/plugins/tts-prefilter.ts, Dudoxx CUDA TTS — Kokoro is deprecated) — strips<think>, working memory, markdown, "section is complete" hallucinations. - Silence handler: first re-prompt at 6 s, then 2 s check, subsequent at 20 s — beats LiveKit's ~8 s idle exit.
- Transcript dedup: 3 s window prevents duplicate STT resumes.
- Why a separate Node process? LiveKit Agents native runtime is Node +
@livekit/rtc-node; embedding it in NestJS adds churn and crashes (C++ mutex on disconnect —MEMORY_LIVEKIT.md). - Why suspend/resume instead of LLM-driven state? LLMs cannot reliably track form completion — see feedback_voice_state_machine_design memory shard: in both v3 FSM and v4 Mastra, the orchestrator MUST detect completion; LLM produces conversation, not transitions.
Data Flow
- Author: clinician creates a scenario via ddx-web →
ScenarioService.create(ddx-api/src/ai/flowagent/services/scenario.service.ts) →FlowAgentScenario+FlowAgentScenarioVersionwith a JSON snapshot built bybuildSnapshotinscenario-data.mapper.ts. - Start session: ddx-web posts
POST /flowagent/sessions→SessionService.create(session.service.ts) → creates a linkedChatSession(TUCAN dispatch route) → publishes SSE onSSEChannels.session(...). - Compile workflow: the FlowAgent Node process fetches the scenario snapshot + form definition →
workflow-compiler.compile()returns a Mastra workflow with[greeting, section_1, …, section_N, completion]. - LiveKit room joined:
ddx-livekit-agents-ts/agents/flowagent/src/agents/flowagent-voice.ts(livekit-flowagent-ts.md) wires STT (Dudoxx CUDA / Deepgram) + TTS (Dudoxx CUDA) + LLM provider. - Per turn:
- STT
TranscriptionFinal→ workflowbridge.resume({ transcript }). - Section step calls
createSectionAgent(...).generate(...) (timeout 30 s —workflow-steps.ts:33). - Tool calls (
fill_field/skip_field) updateFlowAgentState(workflow-state.ts). trackFieldsFromToolCalls()updates handled-field set.- If
allFieldsHandled→ return output (auto-advance). Else → sanitised assistant text → TTS →state_changedDataChannel event.
- STT
- Persistence: every
fill_field/skip_fieldhits ddx-api/flowagent/sessions/{id}viaruntime.service.ts;checkpoint.service.tssnapshots state for crash recovery;audit.service.tslogs transitions. - Frontend state:
useFlowAgentSSE(ddx-web/src/lib/hooks/flowagent/useFlowAgentSSE.ts) +useFlowAgentLiveKit(ddx-web/src/lib/flowagent-client/hooks/useFlowAgentLiveKit.ts) readstate_changed(IDLE/GREETING/NAVIGATING/COLLECTING/SECTION_COMPLETE/COMPLETING/DONE) viaflowagent-client/events/data-channel-handler.ts. HTTP POST = DB persist only; SSE is the source of truth for UI state. (The text/agentic surface folds AG-UI frames instead —useFlowAgentAguiStream+createFlowAgentRunStateFold, see flowagent-agui-bridge.md.)
Implicated Code
MANDATORY: ≥3 file:line citations.
ddx-livekit-agents-ts/agents/flowagent/src/mastra/workflow-compiler.ts:1-9— workflow compiler entry: scenario snapshot + form definition → MastracreateWorkflow()chain.ddx-livekit-agents-ts/agents/flowagent/src/mastra/workflow-compiler.ts:39-83—FormFieldDefinition,SectionOverlay,FieldOverlaytypes (the scenario contract).ddx-livekit-agents-ts/agents/flowagent/src/mastra/workflow-steps.ts:1-13— three step types (greeting / section / completion) using suspend/resume.ddx-livekit-agents-ts/agents/flowagent/src/mastra/workflow-steps.ts:23-26—createStep,MastraModelConfig,Memoryimports.ddx-livekit-agents-ts/agents/flowagent/src/mastra/workflow-steps.ts:33— 30 s agent.generate() timeout to prevent LLM hangs.ddx-livekit-agents-ts/agents/flowagent/src/mastra/workflow-bridge.ts— STT → resume(transcript) bridge.ddx-livekit-agents-ts/agents/flowagent/src/mastra/step-helpers.ts—sanitizeForTTS,trackFieldsFromToolCalls,extractToolCalls,buildSessionContext,buildFieldProgressContext.ddx-api/src/ai/flowagent/services/scenario.service.ts:1-28— scenario CRUD + snapshot building, tenant-scoped.ddx-api/src/ai/flowagent/services/session.service.ts:1-38— session lifecycle, ChatSession linking, SSE publish viaflowagent-events.publisher.ddx-api/src/ai/flowagent/services/runtime.service.ts— snapshot retrieval + checkpoint orchestration.ddx-api/src/ai/flowagent/services/checkpoint.service.ts— state snapshot for crash recovery.
Operational Notes
- Re-seed after form/code changes:
feedback_flowagent_scenarios_reseed.md— if you change form fields or step code, sessions started before the change have a stale snapshot. Re-seed scenarios before testing. - Voice FSM rule (CRITICAL): Orchestrator auto-advances on
allFieldsHandled. NEVER let the LLM say "section is complete" — that hallucination is stripped bysanitizeForTTS, but if you see it in TTS output, the strip regex regressed. thinkingBudgetTokens: 0— non-negotiable for voice. Raising it adds 10-21 s latency.- Cleanup race:
ddx-web/src/lib/flowagent-client/hooks/useFlowAgentLiveKit.tscallsstopVoiceSessionon mount cleanup (React Strict Mode double-render) → known bug, tracked inMEMORY_LIVEKIT.md. - Silence handler: first re-prompt at 6 s, then every 2 s check, subsequent at 20 s.
- Transcript dedup: 3 s window — important when STT emits a final immediately followed by another with corrected casing.
- State channel: UI reads
state_changedfrom DataChannel only. HTTP POSTs to/flowagent/sessions/{id}are DB persistence (no SSE roundtrip). - Mastra version: 1.20.0 in this package; ddx-api's embedded
@mastra/coreis a different line (1.37.1 — verify live). Do NOT cross-import between the two.
Related Topics
- Live STT — produces the
TranscriptionFinalthat resumes a step - Live TTS — consumes the sanitised step output
- Voxial LLM — model + middleware behind each step's agent
- LiveKit FlowAgent TS Bridge — the Node process hosting this engine
- FlowAgent AG-UI Bridge — the text/agentic live surface (staff + guest) for the same scenarios
- Voice Commands — how out-of-form voice intents reach the dispatcher
- AI Assistant Visit Wiring — how a FlowAgent session links to a clinical visit + TUCAN context
- TUCAN Dispatcher (W3) — sibling agent runtime for chat sessions