Case Study | AI | Automation

From WhatsApp Ping to Confirmed Booking: How We Built a Booking Assistant for a Small Medical Clinic

A technical walkthrough of how we implemented a WhatsApp-based booking assistant for a small clinic—covering architecture, state, prompts, integrations, and guardrails.

WhatsAppBooking AssistantHealthcareAutomationCase Study
Let's build

Pick one workflow, and we’ll help you ship it to production.

Back to blog
Mica10 Team avatar

Mica10 Team

Author

Published
Read time 8–10 min read

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 IncomingMessage type.
  • 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 START to WAITING_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.