This is a technical write-up of how we implemented a WhatsApp booking assistant for a small medical clinic using the Mica10 stack.
No theatre, just:
- the constraints we were given
- the architecture we shipped
- the data structures we used
- how we handled state, prompts, and integrations
- where we drew hard guardrails
Everything here is for a small clinic (several practitioners, a single practice management system, one main WhatsApp entry point), but the pattern generalises.
1. The clinic and the constraints
The clinic
- Multi-practitioner outpatient practice (mixed new + follow-up appointments)
- One practice management system that owns calendars, appointment types + durations, and patient records
- One WhatsApp Business number used for bookings, reschedules, and simple questions (“What are your fees?”)
Constraints we agreed up front
- No change to the practice management system UI for staff
- Allowed: propose and confirm simple bookings end-to-end; handle basic FAQs with pre-approved content
- Not allowed: provide clinical advice, triage emergencies, or touch billing/fee waivers
- Every action must be logged with who/what/when and be reversible by a human
2. High-level architecture we shipped
WhatsApp is just a channel adapter. All logic, state, and guardrails live in the orchestrator where we can test and observe them.
Patient (WhatsApp)
|
v
WhatsApp Business API Provider
|
v
Webhook (Ingress API)
|
v
Mica10 Orchestrator
| | |
| | +-> Notification service (staff alerts)
| |
| +-> Scheduling adapter (practice management API)
|
+-> Logging + Metrics + Audit store
3. Data model: conversations, bookings, and traces
We model three core things: a conversation (per WhatsApp chat), a booking session (when a conversation is in “book mode”), and a trace (every decision the workflow makes). Conversation and booking session live in our DB (Postgres), keyed by the WhatsApp ID.
// Conversation row – one per WhatsApp chat
export interface Conversation {
id: string; // internal UUID
channel: 'whatsapp'; // future-proofing for SMS/web chat
externalId: string; // WhatsApp phone / chat id
lastMessageAt: Date;
activeBookingSessionId?: string; // FK into BookingSession if a flow is active
createdAt: Date;
updatedAt: Date;
}
// Booking session – created when we enter the booking flow
export type BookingState =
| 'START'
| 'PATIENT_IDENTIFICATION'
| 'PREFERENCES'
| 'AVAILABILITY_SELECTION'
| 'WAITING_CONFIRMATION'
| 'COMPLETE'
| 'ESCALATED';
export interface BookingSession {
id: string;
conversationId: string;
state: BookingState;
data: {
// progressively filled as we ask questions
patientName?: string;
dobOrIdentifier?: string;
appointmentType?: 'NEW' | 'FOLLOW_UP';
clinicLocationId?: string;
preferredPractitionerId?: string;
preferredWindowStart?: string; // ISO datetime
preferredWindowEnd?: string; // ISO datetime
proposedSlotId?: string;
confirmedAppointmentId?: string;
// extra flags
isExistingPatient?: boolean;
};
createdAt: Date;
updatedAt: Date;
}
3.2 Traces and audit events
Every time the orchestrator does something non-trivial, it emits a trace event.
// Minimal trace event structure pushed to an audit/log stream
export interface TraceEvent {
id: string;
conversationId: string;
bookingSessionId?: string;
type:
| 'INCOMING_MESSAGE'
| 'INTENT_CLASSIFIED'
| 'STATE_TRANSITION'
| 'SCHEDULING_API_CALL'
| 'APPOINTMENT_BOOKED'
| 'ESCALATED_TO_HUMAN';
payload: unknown; // small JSON blob with relevant details
createdAt: Date;
}
These events feed our logs, metrics, and a simple ops UI to replay any conversation.
4. WhatsApp ingress: webhook and normalisation
The ingress service is intentionally thin:
- Validates the webhook (signature, IP allowlist).
- Normalises into our
IncomingMessagetype. - Persists the raw payload for audit/support.
- Hands off to the orchestrator via an internal queue/API.
Only channel-shaped payloads live at the edge; everything else is normalised before it touches the workflow.
We keep the provider-specific shape at the edge, then forget about it as quickly as possible.
5. Orchestration: how a single message flows
At the core we have a single message handler that:
- Loads/creates a
Conversation. - If there’s an active booking session, continues that flow.
- Otherwise, classifies intent and routes to booking/FAQ/emergency/unknown.
- Emits trace events on every significant step (incoming, classified, state transition).
That keeps the branching logic small and makes sessions resumable.
6. Intent classification: LLM + hard safety rules
We use an LLM for soft classification, then enforce hard safety overrides.
export type Intent = 'BOOKING' | 'FAQ' | 'TRIAGE_EMERGENCY' | 'ADMIN' | 'UNKNOWN';
const EMERGENCY_KEYWORDS = [
'chest pain',
"can't breathe",
'cant breathe',
'severe pain',
'heavy bleeding',
'lost consciousness',
'passed out',
'blurred vision',
'sudden weakness',
];
function looksLikeEmergency(text: string): boolean {
const lower = text.toLowerCase();
return EMERGENCY_KEYWORDS.some((kw) => lower.includes(kw));
}
async function classifyIntent(rawText: string): Promise<Intent> {
if (looksLikeEmergency(rawText)) return 'TRIAGE_EMERGENCY';
const llmResult = await llmClassify(rawText); // constrained JSON response
switch (llmResult.intent) {
case 'booking':
return 'BOOKING';
case 'faq':
return 'FAQ';
case 'administrative':
return 'ADMIN';
default:
return 'UNKNOWN';
}
}
For the clinic, we set the emergency copy and escalation path with their clinical governance team, then hard-coded it in this layer.
7. The booking state machine in practice
We model the booking conversation as a state machine. Each state knows which fields it needs, decides the next state, and emits exactly one question or confirmation message.
const bookingHandlers: Record<BookingState, BookingStateHandler> = {
START: handleStart,
PATIENT_IDENTIFICATION: handlePatientIdentification,
PREFERENCES: handlePreferences,
AVAILABILITY_SELECTION: handleAvailabilitySelection,
WAITING_CONFIRMATION: handleWaitingConfirmation,
COMPLETE: handleComplete,
ESCALATED: handleEscalated,
};
export async function continueBookingFlow(
conversation: Conversation,
session: BookingSession,
incoming: IncomingMessage,
) {
const handler = bookingHandlers[session.state];
const { session: updated, replyText } = await handler({ conversation, session, incoming });
await bookingSessionsRepo.update(updated.id, updated);
await traceRepo.insert({
id: generateId(),
conversationId: conversation.id,
bookingSessionId: updated.id,
type: 'STATE_TRANSITION',
payload: { from: session.state, to: updated.state },
createdAt: new Date(),
});
if (updated.state === 'COMPLETE' || updated.state === 'ESCALATED') {
await conversationsRepo.update(conversation.id, { activeBookingSessionId: null });
}
return { text: replyText, channel: 'whatsapp', to: conversation.externalId };
}
Example: START and PATIENT_IDENTIFICATION
// Initial state – welcome and move straight into identification
async function handleStart({ session }: { session: BookingSession }): Promise<{ session: BookingSession; replyText: string }> {
session.state = 'PATIENT_IDENTIFICATION';
const replyText =
'I can help you request an appointment at the clinic.\n\n' +
'First, who is this booking for? Please reply with their full name.';
return { session, replyText };
}
// Collect patient name and basic identifier (DOB or clinic ID)
async function handlePatientIdentification({ session, incoming }: { session: BookingSession; incoming: IncomingMessage }) {
if (!session.data.patientName) {
session.data.patientName = incoming.text.trim();
const replyText =
`Thanks, ${session.data.patientName}.\n\n` +
'Do we already know you at the clinic?\n' +
'- Reply "YES" if you\'ve been here before.\n' +
'- Reply "NO" if this is your first visit.';
return { session, replyText };
}
const lower = incoming.text.toLowerCase();
if (lower.startsWith('y')) {
session.data.isExistingPatient = true;
session.state = 'PREFERENCES';
const replyText =
'Got it. For existing patients we\'ll match you to your record.\n\n' +
'Which clinic location and practitioner do you prefer? ' +
'You can say something like "Main clinic with Dr Smith" ' +
'or "Any physio at Central clinic".';
return { session, replyText };
}
if (lower.startsWith('n')) {
session.data.isExistingPatient = false;
session.state = 'PREFERENCES';
const replyText =
'Welcome! We\'ll create a new record for you when we confirm your booking.\n\n' +
'Which clinic location and practitioner do you prefer?';
return { session, replyText };
}
const replyText =
'Please reply "YES" if you\'ve been to the clinic before, or "NO" if this is your first visit.';
return { session, replyText };
}
8. Scheduling: talking to the practice management system
We wrapped the clinic’s practice management API with a thin adapter that:
- Maps our logical appointment types to vendor identifiers.
- Maps human-readable clinician names to IDs.
- Enforces business rules (no new patients in certain slots, etc.).
// Adapter that wraps the clinic's practice management API
export class PracticeManagementSchedulingService implements SchedulingService {
constructor(private client: PracticeManagementApiClient) {}
async findAvailableSlots(params: {
clinicLocationId: string;
practitionerId?: string;
appointmentType: 'NEW' | 'FOLLOW_UP';
windowStart: Date;
windowEnd: Date;
}): Promise<AvailableSlot[]> {
const vendorAppointmentTypeId = mapToVendorAppointmentTypeId(
params.appointmentType,
);
const vendorSlots = await this.client.getAvailability({
locationId: params.clinicLocationId,
practitionerId: params.practitionerId,
appointmentTypeId: vendorAppointmentTypeId,
from: params.windowStart.toISOString(),
to: params.windowEnd.toISOString(),
});
return vendorSlots.map((s) => ({
id: s.slotId,
start: new Date(s.start),
end: new Date(s.end),
practitionerId: s.practitionerId,
humanReadable: formatSlotForPatient(s),
}));
}
async bookSlot(params: {
slotId: string;
patientExternalId?: string;
patientProvisionalDetails?: { name: string };
reason: string;
channel: 'WHATSAPP';
}): Promise<{ appointmentId: string }> {
const patientId = params.patientExternalId
? params.patientExternalId
: await this.client.createProvisionalPatient({
name: params.patientProvisionalDetails?.name ?? 'Unknown',
channel: 'WHATSAPP',
});
const result = await this.client.bookSlot({
slotId: params.slotId,
patientId,
notes: `[Channel: WHATSAPP] ${params.reason}`,
});
return { appointmentId: result.appointmentId };
}
}
Keeping the adapter small and explicit avoids leaking vendor-specific details into the rest of the workflow.
9. Guardrails and tests
Guardrails live in code, not just prompts. We added unit tests around:
- Intent classification rules (especially emergency vs non-emergency).
- State transitions (you can’t jump straight from
STARTtoWAITING_CONFIRMATION). - Scheduling behaviour (never book outside clinic hours).
// Jest-style test for booking state machine transitions
test('booking flow moves from START -> PATIENT_IDENTIFICATION on first message', async () => {
const conversation: Conversation = fakeConversation();
const session: BookingSession = {
id: 'session-1',
conversationId: conversation.id,
state: 'START',
data: {},
createdAt: new Date(),
updatedAt: new Date(),
};
const incoming: IncomingMessage = {
eventId: 'evt-1',
channel: 'whatsapp',
externalConversationId: conversation.externalId,
externalSenderId: conversation.externalId,
text: 'Hi, I want to book an appointment',
receivedAt: new Date(),
};
const reply = await continueBookingFlow(conversation, session, incoming);
const updated = await bookingSessionsRepo.get(session.id);
expect(updated.state).toBe('PATIENT_IDENTIFICATION');
expect(reply.text).toMatch(/full name/i);
});
We also replay anonymised historical WhatsApp messages through the classifier and booking flow to catch regressions whenever prompts, rules, or models change.
10. Observability and operations
We exposed a small internal dashboard that showed:
- Recent WhatsApp conversations and whether each was fully automated, partially automated then escalated, or immediately escalated.
- The final outcome (booked, no availability, patient dropped off).
- Drill-down via Trace events joined to conversations and booking sessions.
This gave the clinic’s operations lead confidence that nothing was “going rogue,” plus a place to spot patterns (“everyone is asking about Saturday bookings we don’t offer”) and a starting point for refining copy and flows.
11. What this looked like in practice
By the end of the initial rollout:
- Simple “I want to book a follow-up with my usual practitioner” messages were fully automated.
- Edge cases (complex schedules, multiple complaints, anything that looked clinical) were escalated with full context.
- Staff stayed in their existing practice management system—no new tools to learn.
- We had clear metrics and full audit trails for every booking the assistant touched.
The core win wasn’t a clever LLM trick—it was a clean separation between channel and logic, a small state machine for bookings, a thin scheduling adapter, and guardrails baked in from the first commit. It’s the pattern we now reuse whenever we stand up a new booking assistant.