AI Medical Tools — Clinical Tool Catalog
Audiences: doctor, developer, investor
TUCAN's reach into the EMR is a catalog of typed Mastra tools — ~350
createTucanTooldefinitions across ~35 categories — each one a thin wrapper around a NestJS service call that mutates Prisma or FHIR or publishes a UI side effect over SSE.
Business Purpose
The tool catalog is what TUCAN can actually do. A "clinical assistant" is only as useful as the actions it can take in the real system; Dudoxx's edge is that every clinical, scheduling, billing, and document action the doctor can do by clicking is also available to TUCAN by call.
Because every tool is the same code path as the UI (a NestJS service call through the Trusted Gateway), every safeguard the UI has — RBAC, tenant isolation, audit log, FHIR partition scoping — applies automatically to the LLM. The LLM is not a privileged actor; it is a typed remote control of the existing controllers.
Tools also serve a second business purpose: they are the surface
through which TUCAN renders UI. Modals, notifications, info-containers,
patient drawer focus, document previews, navigation — all happen by the
LLM calling a category: 'ui' tool that publishes a ui:action SSE
event instead of mutating data.
Audiences
- Investor: The catalog is the moat made concrete. ~350 typed surfaces — write a prescription, book an appointment, draft a referral letter, open a modal — each one a one-week-to-add unit of product scope. Competitors must redo the integration work clinic by clinic; Dudoxx amortizes it across tenants.
- Clinical buyer (doctor / nurse / receptionist): Whatever you do in the UI, you can ask for in plain language. Order labs, prescribe, schedule, draft letters, review interactions, summarize a chart, search ICD-10 — the same RBAC rules apply, the same audit trail is written, the same patient is scoped.
- Developer / partner: Adding a tool is
createTucanTool({ id, description, inputSchema, metadata, execute })plus a registry entry. The Mastra DI surface (createDIContainerfrom@mastra/core) gives theexecutefunction direct access to every injected NestJS service via thecontext.servicesobject — no parallel API stack. - Internal (ops / support): Every tool call writes a
ChatMessage{ role:'tool' }row and aTucanUsageRecordrow. "Did the AI do X?" becomes a SQL query onchat_messages.tool_calls.
Architecture
The catalog has three layers:
┌──────────────────────────────────────────────────────────────┐
│ Tool definition │
│ createTucanTool({ │
│ id: 'create-prescription', │
│ description: '...', │
│ inputSchema: z.object({ ... }), ← Zod v4 │
│ metadata: { │
│ category: 'medications', │
│ ragDescription: '...', │
│ ragKeywords: [...], │
│ mutating: true, │
│ alwaysInclude: false, │
│ }, │
│ outputInstructions: 'NEVER show UUIDs...', │
│ execute: async (input, ctx) => { /* call service */ }, │
│ }) │
└──────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ Tool registry (ddx-api/src/ai/tucan/tools/registry/*) │
│ · workflow-tool-capabilities — what each tool needs │
│ · workflow-tool-map — which workflows can use it │
│ · agent-type catalog → category allow-list │
└──────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ Per-agent tool selection │
│ AgentRegistryService binds agent → tools at construction │
│ (static category filter, not per-turn semantic reduction) │
│ tool-converter.ts adapts createTucanTool → Mastra Tool │
└──────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ Execution at LLM call time │
│ Mastra Agent.generate() dispatches the LLM's chosen tool │
│ execute() calls context.services.* (NestJS DI) │
│ side effects: │
│ · Prisma mutation │
│ · FHIR write via FHIR_CLIENT / ResourceFacade │
│ · SSE ui:action / tool:start|progress|complete │
└──────────────────────────────────────────────────────────────┘
Tool categories (~35 directories under tools/)
| Category | Purpose |
|---|---|
core/ | create-tool.ts factory + Mastra primitives |
_shared/, common/ | shared schemas + helpers |
patients/ | patient CRUD + lookup |
visit/ | visit lifecycle, active visit context |
clinical/, clinical-notes/ | progress notes, examinations |
medications/, prescriptions/ | order-entry, drug interaction checks |
allergies/, conditions/, immunizations/, vitals/ | structured clinical data |
diagnosis/, diagnostics/ | diagnostic reports, lab orders |
service-requests/ | FHIR ServiceRequest |
assessment/ | intake + assessment forms |
intake/ | patient-intake workflow tools |
scheduling/ | slots, appointments, recurring |
fhir/ | 12 read-only FHIR R4 tools |
document/ | doxing, templates, generation, OCR-bound |
communication/, messenger/ | email / SMS / WhatsApp / Telegram |
tasks/ | task assignment + status |
insurance/ | insurance coverage lookup |
rag/, deep-search/, knowledge/ | semantic search + Qdrant |
flowagent/ | scenario step orchestration (voice) |
voice/ | reply-to-operator (voice-only) |
utility/ | get-current-time, search-icd10, calculate-bmi |
session/ | link-patient, get-session-context, rename-session |
ui/ | UI-events tools — see next section |
UI-events tools — the publisher-side contract
UI tools are the way TUCAN drives the frontend without leaking display
concerns into clinical mutations. Every tool under tools/ui/ has
category: 'ui', mutating: false, calls no FHIR / Prisma, and
publishes one or more ui:action SSE events via
context.services.eventBus.publishUIAction(...).
Implemented today (verified 2026-05-18):
| Tool ID | Action type | Purpose |
|---|---|---|
show-modal | show_modal | 9 modal variants — alert, confirmation, info, html_content, patient_info, fhir_resource, medication_list, icd10_result, drug_interaction |
show-notification | show_notification | toast / banner, 1-30s, no DB |
show-document-viewer | show_document_viewer | document preview, no DB |
update-info-container | update_info_container | 11 sidebar variants, DB-upsert (the only mutating UI tool, conceptually) |
clear-info-containers | DB only | clears the sidebar table — does NOT publish SSE (the DB change is silent to the frontend; a regression to file) |
navigate-to | navigate | 10 page types, no DB |
play-audio-feedback | play_audio_feedback | voice channel chime |
focus-patient-drawer | open-drawer | patient context drawer |
open-patient-card-modal | open-modal (open_patient_card) | rich patient overview |
open-clinical-summary-modal | open-modal (open_clinical_summary) | recent visits / vitals / meds rollup |
open-document-preview | open-preview | one-shot doc preview |
open-table-view | open-modal (open_table) | data-grid for lists |
Hallucinated / not yet implemented (the LLM may try them; the
frontend dispatcher logs handler:dropped):
open_panel/close_panel— no backend tool nor frontend caserender_component— no frontend switch case for arbitrary components yetget_clinical_notes— wrong name; real tool islist-clinical-notes
UI tools that emit a modal must include a modalId: randomUUID() in
the published payload — the frontend useTUCANUIActions hook dedupes by
modalId and silently drops actions without one
(tools/ui/show-modal.tool.ts:117).
The full SSE wire format for ui:action is owned by [[sse-event-engine]]
(W1). On the consumer side, the React hooks and AG-UI bridge live in
ddx-web (see W5 frontend page once filled; for now the contract anchor
is ddx-web/src/lib/sse/global-manager.ts and the
useTucanSSE / useTUCANUIActions hooks documented in the
ddx-ai-ui-events-frontend skill).
Tech Stack & Choices
| Choice | Rationale |
|---|---|
createTucanTool({...}) typed factory | Single point to enforce metadata (category, ragDescription, ragKeywords, mutating, alwaysInclude) — review checklist (~/.claude/rules/shared/review-checklists/mastra.md) bans tools without ragDescription. |
Zod v4 (zod/v4) input schemas | Backend rule: import { z } from 'zod/v4'. Bare 'zod' is reserved for ddx-web (Next.js). |
LLM-perspective ragDescription | "USE WHEN / DO NOT USE WHEN / REQUIRES / PRODUCES" four-line shape — see show-modal.tool.ts:79-84. Drives tool-RAG retrieval ([[tucan-rag-tools]]). |
Multilingual ragKeywords (EN + DE minimum) | Dudoxx is German-first; the tool-RAG embedding space must match the operator's language. |
| `mutating: true | false` flag |
| `alwaysInclude: true | false` |
outputInstructions field with "NEVER show UUIDs" | Mandatory when the output contains IDs; mastra checklist rule. |
| Per-agent static category filter | The agent gets its tool list at new Agent({ tools: filterByCategory([...]) }) time. No per-turn semantic reduction — see [[tucan-rag-tools]] for the dormant SkillSearchProcessor pitfall. |
| Hyphenated tool IDs | Mastra auto-converts 'show-modal' to show_modal for the LLM. Non-hyphenated IDs collide with this conversion. |
Frontend deduping by modalId / containerId | SSE has at-least-once semantics; the consumer must dedupe. The publisher must provide a stable ID. |
Data Flow
Business outcome: A doctor types "Check drug interactions for the current med list before I add metformin." TUCAN reads the patient's active medications from FHIR, runs the drug-interaction tool, and opens a drug-interaction modal with severity colors — no data mutation, just a UI side effect.
Technical mechanism:
- Dispatcher resolves agent
'clinical', which has themedications,clinical,fhir,uicategories bound at construction (ddx-api/src/ai/tucan/registry/agent-type.registry.ts).- LLM emits a tool call:
list-active-medications(categorymedications).- The tool's
execute(input, ctx)callsctx.services.medicationsService.listActive(...)— the real NestJS service, with the doctor's tenant header forwarded.- SSE
tool:start→tool:completeevents stream (ddx-api/src/ai/tucan/sse/event-bus.service.ts).- LLM emits a second tool call:
check-drug-interactions(input).- The tool's
executecalls a downstream interaction service + returns structured{ pairs: [...] }.- LLM emits a third tool call:
show-modal({ variant:'drug_interaction', title:'Interactions', data: pairs }).show-modal.execute(tools/ui/show-modal.tool.ts:113) generates amodalId, callsctx.services.eventBus.publishUIAction(sessionId, 'show_modal', 'modal', { modalId, variant, title, ... }).- ddx-web
useTUCANUIActionshook receives the SSE, dedupes onmodalId, renders the modal.
Implicated Code
ddx-api/src/ai/tucan/tools/core/create-tool.ts—createTucanToolfactory; the typed seam every tool flows through.ddx-api/src/ai/tucan/tools/ui/show-modal.tool.ts:11— canonical UI tool implementation; 9-variant modal.ddx-api/src/ai/tucan/tools/ui/show-modal.tool.ts:77—metadata.category: 'ui'discriminator.ddx-api/src/ai/tucan/tools/ui/show-modal.tool.ts:79-84—ragDescriptionLLM-perspective shape (USE WHEN / DO NOT USE WHEN / REQUIRES / PRODUCES).ddx-api/src/ai/tucan/tools/ui/show-modal.tool.ts:104—mutating: false(UI tool — no DB mutation).ddx-api/src/ai/tucan/tools/ui/show-modal.tool.ts:113—executepublishesui:actionover SSE.ddx-api/src/ai/tucan/tools/ui/show-modal.tool.ts:117— mandatorymodalId: randomUUID()for frontend dedupe.ddx-api/src/ai/tucan/tools/ui/show-notification.tool.ts— toast/banner.ddx-api/src/ai/tucan/tools/ui/update-info-container.tool.ts— the one UI tool that also writes a Prisma row (info-container table).ddx-api/src/ai/tucan/tools/ui/clear-info-containers.tool.ts— DB-only; does NOT publish SSE (regression to file).ddx-api/src/ai/tucan/tools/ui/navigate-to.tool.ts— 10 page types.ddx-api/src/ai/tucan/tools/ui/index.ts— barrel export of all 13 UI tools.ddx-api/src/ai/tucan/tools/registry/workflow-tool-capabilities.ts— capabilities per tool (which workflows can use it).ddx-api/src/ai/tucan/tools/registry/workflow-tool-map.ts— workflow → allowed-tools map.ddx-api/src/ai/tucan/registry/tool-converter.ts— adapter fromcreateTucanToolto MastraTool.ddx-api/src/ai/tucan/registry/tool-selector.ts— per-agent tool filtering by category.ddx-api/src/ai/tucan/registry/agent-type.registry.ts— agent → category allow-list.ddx-api/src/ai/tucan/registry/tool-choice-guard.ts— guard that prevents the LLM from picking a tool outside the bound catalog.
Operational Notes
- The catalog count drifts. As of 2026-05-18,
rg -c "createTucanTool" ddx-api/src/ai/tucan/toolsreports 354 calls across 168 files. Project memory previously said "130 tools" (memory shard MEMORY_TUCAN_AI 2026-04-24) and "233 tools" (dispatch brief). The single source of truth is the registry walk at runtime — do not quote a static number in marketing copy without re-counting. - Mastra hyphen↔underscore rule. Tool
id: 'show-modal'is exposed to the LLM asshow_modal. Mixed forms cause silent dispatch failures. The frontend action type uses the underscore form (e.g.show_modal,show_notification,update_info_container). get_clinical_notesis a hallucination. Real tool:list-clinical-notes.open_panel/close_panel/render_componentare unimplemented — the LLM may emit them, the frontend dispatcher logshandler:dropped. Either implement them, or strip them from any agent prompts that reference them.clear-info-containersdoes NOT publish SSE — the DB change is invisible to the frontend until the next page mount. Track as a known defect; do not write a follow-up tool that depends on the frontend learning the table was cleared.- Mastra review checklist applies to every new tool: ragDescription,
ragKeywords (EN + DE), mutating flag, outputInstructions ("NEVER
show UUIDs" when output contains IDs), error result includes
suggestionfield. See~/.claude/rules/shared/review-checklists/mastra.md. - Composite tools cannot call other tools. Mastra tools cannot
invoke each other from
execute()without a registry shim — leave composite logic in the NestJS service path ([[ddx-mastra-specialist_memory.md]]). - ragKeywords must distinguish siblings. Identical
ragDescription+ragKeywordsproduces identical embeddings; retrieval picks the wrong tool. Add anti-synonyms ("NOT encounter", "NOT fhir-encounter") and a canonical TOOL_ID suffix when two tools serve adjacent intents — pitfall confirmed in commitf6eef8a18.
Related Topics
- [[tucan-assistant]] — orchestration entry point that dispatches tools.
- [[tucan-dispatcher]] — the per-stage state machine that calls
DispatcherStagewhich finally invokes MastraAgent.generate. - [[tucan-rag-tools]] — Qdrant-backed tool discovery (build-time only, not per-turn).
- [[tucan-intent-filtering]] — intent-classifier that narrows the agent before per-agent category filtering.
- [[agent-templates]] — agent template factory consumed by agent-type.registry.
- [[tucan-context-builder]] — context.services injection (the DI
bridge from NestJS into Mastra tool
execute). - [[sse-event-engine]] (W1) —
ui:actionenvelope owner. docs/claude-context/CLAUDE_TUCAN_API_INJECTION.md— tool registry + RAG filtering pattern.~/.claude/rules/shared/review-checklists/mastra.md— mandatory pre-commit checklist for new tools.~/.claude/skills/ddx-ai-ui-events-frontend/SKILL.md— frontend consumer side.~/.claude/skills/ddx-ai-ui-events-backend/SKILL.md— publisher side reference.