First booking
Walk a real haircut booking through the production code path on your laptop. The customer sends "Hi" on WhatsApp and ends up with a confirmed appointment and an M-Pesa STK push. This is the end-to-end check that the most-trodden code path is alive.
This page assumes Dev setup is green and a tenant with catalog + staff is already provisioned (see Onboard a tenant).
Booking happy-path FSM
The diagram below traces the state path a message like "Hi I want to book a haircut tomorrow at 3pm" takes through the LangGraph booking graph. For the full FSM — all edges, all branching conditions, admin handoff states, cancellation, reschedule — see Conversation FSM.
The single first message in this walkthrough ("Hi I want to book a haircut tomorrow at 3pm") contains intent + service + slot, so the FSM skips COLLECT_SERVICE and COLLECT_SLOT entirely, jumping from GREET directly to CONFIRM in one classifier pass.
Pre-flight
Three gates before you start chatting:
./scripts/pilot-preflight.shis all-green. This validates required env vars (LLM keys, WhatsApp app secret, LiveKit), alldocker composecontainers are healthy, andGET /healthzreturns 200.- A test tenant is onboarded with at least one service and one staff member with a schedule. The booking graph reads from
tenant_<slug>.servicesandtenant_<slug>.staff_schedules— if either is empty the FSM stalls atCOLLECT_SERVICEorCOLLECT_SLOT. See Onboard a tenant. - The Meta WhatsApp test phone is configured. Either Meta's free dev number (+1 555 area, 5 verified test recipients) or your own number provisioned via Twilio long-code per ADR-0008. The
whatsapp_phone_number_idmust be on thepublic.tenantsrow and the test phone must be on Meta's verified-recipients list for that number.
Step 1 — Send the first message
Open WhatsApp on your phone. Message the Meta test number configured on the tenant:
Hi I want to book a haircut tomorrow at 3pm
That single line carries enough information to skip three FSM states. The intent classifier detects book, matches the service to haircut, and extracts the slot from "tomorrow at 3pm".
Step 2 — Watch the FSM transition
In a separate terminal, tail the backend log:
tail -f backend/.uvicorn.log
You should see roughly five log lines in sequence:
INFO webhook.whatsapp POST /webhooks/whatsapp signature_valid=True
INFO classifier intent=book confidence=0.94 lang=en
INFO booking_graph state=GREET → COLLECT_SERVICE thread=01HXY...
INFO booking_graph state=COLLECT_SERVICE → CONFIRM service=haircut slot=2026-06-01T15:00 staff=Anna
INFO daraja.stk initiating amount=1500 phone=+254712...
Within a second or two, your phone should buzz with the agent's reply:
To confirm: Haircut tomorrow at 15:00 with Anna for KES 1,500. Reply YES to confirm.
That is the FSM in CONFIRM state awaiting your yes/no.
To inspect the live FSM state writes in Redis directly:
docker compose exec redis redis-cli -a ratiba_redis_password
> MONITOR
You will see per-thread state writes as the dispatcher persists each turn. State keys are prefixed by tenant:<slug>:thread:<thread_id> per ADR-0003. The 90-day retention reaper and the checkpoints_archive table are explained in Conversation FSM.
For guidance on reading structured logs and interpreting classifier confidence scores, see Observability.
Step 3 — Confirm and pay
Reply on WhatsApp:
yes
The FSM transitions CONFIRM → PAYMENT_PENDING. The Daraja STK push fires. Your phone (or the Daraja sandbox simulator at https://developer.safaricom.co.ke/test_credentials) gets the M-Pesa pop-up:
Pay KES 1500.00 to Ratiba Spa Pilot. Enter M-Pesa PIN.
Approve. The Daraja callback fires POST /webhooks/daraja/callback; the dispatcher correlates by CheckoutRequestID and transitions the FSM PAYMENT_PENDING → BOOKED. The agent sends the final confirmation:
Booked! Haircut tomorrow at 15:00. See you then.
For the full payment flow — the 8-minute nudge timer, the one-shot stkpushquery reconciliation poll at t=60s, the PAYMENT_CANCELLED_BY_CUSTOMER first-class FSM state, the PesaPal card path, and the daily 3 AM EAT reaper — see Payments.
Step 4 — Verify the booking
The conversation thread is now flushed from Redis hot state (per ADR-0003). The LangGraph Postgres checkpoint is the durable record.
Verify the appointment row:
docker compose exec postgres psql -U ratiba ratiba \
-c "SELECT id, customer_phone, service_id, starts_at, status \
FROM tenant_spa_pilot.appointments \
ORDER BY created_at DESC LIMIT 5;"
Expect one row with status='confirmed' and starts_at matching the proposed slot.
Verify payment reconciliation:
docker compose exec postgres psql -U ratiba ratiba \
-c "SELECT amount_kes, mpesa_receipt, state \
FROM tenant_spa_pilot.payments \
ORDER BY created_at DESC LIMIT 5;"
state='settled' and a populated mpesa_receipt confirm reconciliation closed the loop.
What just happened
You exercised four core subsystems end-to-end:
- Channel substrate — WhatsApp Cloud API webhook receive + HMAC verification +
parse_inbound. See Channel substrate. - Conversation FSM — single bilingual intent classifier, LangGraph booking graph, Redis hot state (90s SETNX mutex, exponential backoff), Postgres LangGraph checkpointer. See Conversation FSM.
- Payments — Daraja STK push, one-shot
stkpushqueryreconciliation at t=60s,CheckoutRequestID-correlated FSM state update. See Payments. - Multi-tenancy — every read and write went through
tenant_spa_pilot.*, neverpublic.*business data. See Identity and tenancy.
The agent did not need to ask follow-up questions because the first message carried intent + service + time. Try a deliberately under-specified turn next ("I need an appointment") and watch the FSM walk interactively through COLLECT_SERVICE then COLLECT_SLOT.
Troubleshooting
STK push never arrived on the phone. Check Daraja sandbox configuration on the tenant. pilot-preflight.sh validates env vars but not per-tenant Daraja credentials. Confirm tenant_spa_pilot.payments has a row in state='initiated' — if not, the dispatcher never called Daraja and the issue is upstream (FSM did not reach PAYMENT_PENDING). If the row exists but no callback arrived, check the backend log for daraja.client errors — the most common cause is shortcode/passkey mismatch.
Agent never replied to "Hi". The most common failure is HMAC signature verification. WHATSAPP_APP_SECRET must match the App Secret on developers.facebook.com for your WhatsApp app. Check the log for webhook.whatsapp signature_valid=False — that is the smoking gun. See Observability for structured-log query patterns.
FSM stuck in COLLECT_SLOT. The tenant's staff_schedules table is empty or has no slot matching the request. Add a staff member with a recurring schedule via /admin/catalog. Quick-fix for local dev:
INSERT INTO tenant_spa_pilot.staff_schedules
(staff_id, day_of_week, start_time, end_time)
VALUES
((SELECT id FROM tenant_spa_pilot.staff LIMIT 1), 1, '09:00', '18:00'),
((SELECT id FROM tenant_spa_pilot.staff LIMIT 1), 2, '09:00', '18:00'),
((SELECT id FROM tenant_spa_pilot.staff LIMIT 1), 3, '09:00', '18:00'),
((SELECT id FROM tenant_spa_pilot.staff LIMIT 1), 4, '09:00', '18:00'),
((SELECT id FROM tenant_spa_pilot.staff LIMIT 1), 5, '09:00', '18:00');
Agent replies but the language is wrong. Ratiba runs a single bilingual classifier per ADR-0005. If a Swahili customer is getting English responses, language confidence on the inbound message was low and the tenant's locale fallback fired. Check the classifier log line: lang=en confidence=0.42 means low-confidence detection. Set the tenant's default locale to sw for a Kenya-vertical tenant to flip the fallback language:
docker compose exec postgres psql -U ratiba ratiba \
-c "UPDATE public.tenants SET locale='sw' WHERE slug='spa_pilot';"
tenant_spa_pilot.appointments returns nothing after a successful chat. The FSM completed but the dispatcher's appointment-write step crashed. Search the log for appointment.create and look for the stack trace. Most common cause: a uniqueness violation on (staff_id, starts_at) because you booked the same slot twice in a row. See Observability for log querying tips.
What next
- Cross-sell — book two services at once and watch the agent offer a bundle discount.
- Personality dials — change the agent's tone via
/admin/personality; the next conversation will feel different. - Voice conversation — call instead of messaging; same FSM, different I/O adapter with full-duplex barge-in.