Human-in-the-Loop Handoff Model for Ratiba
Status: Research deliverable. Feeds candidate ADR-0006. Audience: Adrian + future Ratiba contributors deciding the canonical handoff model before any orchestration code is written. Voice: Opinionated. Specific. Prescriptive where evidence is strong; honest where it isn't.
This document builds on three already-locked decisions (ADR-0001 amended 2026-04-25): Python 3.13, LangGraph adopted via the TenantScopedSaver wrapper (Option A from the PostgresSaver spike), and psycopg/asyncpg coexistence. It does not re-litigate any of them. It assumes the reader has skimmed §3 of the Phase C landscape scan and the spike verdict.
1. Recommended handoff model
The canonical Ratiba handoff is a LangGraph interrupt(payload) checkpoint that suspends the booking FSM into a HUMAN_DRIVING state, surfaces a bilingual context brief to the registered admin over the same WhatsApp thread the customer is already on, lets the admin send messages directly to the customer (the agent stays out of the way and acts as a passive transcript recorder), and resumes via Command(resume=...) when the admin signals hand-back through a magic phrase or the dashboard. The whole lifecycle is: trigger (confidence threshold breached, explicit user request, unrecoverable tool error, or admin-pull) → persist FSM state via TenantScopedSaver → brief admin in their preferred language with last N turns + LLM summary + suggested next action → admin engages customer directly on the same thread (admin's WhatsApp DID is the tenant's registered admin number; their messages are forwarded verbatim to the customer through the same 360dialog channel) → admin signals done ("rudisha kwa AI" / "agent take over" / dashboard button) → agent resumes with Command(resume={"admin_summary": "...", "outcome": "..."}) and re-greets the customer with a short reorientation. Voice handoff in Phase 2 follows the same skeleton but bridges through LiveKit's WarmTransferTask instead of WhatsApp message-passing. Conversation state remains canonical throughout — the FSM never goes home; it just changes who is driving.
2. Confidence triggers
Phase C §3 anchors the pattern: confidence-driven routing is the default mode in 2026 production agent stacks; the question is the threshold, not the mechanism. For Ratiba the trigger set is multi-modal — no single signal is sufficient — and every trigger must produce an audit-loggable reason code so the admin's brief can say why they were paged.
| Trigger code | Condition | Notes |
|---|---|---|
LOW_CONF_INTENT | Intent classifier confidence below 0.6 for 3 consecutive turns. | Confidence threshold per Anthropic constrained-output strict mode + our internal classifier. The "3 consecutive" rule prevents single-utterance flapping. |
LOW_CONF_SLOT | Slot-extractor confidence below 0.55 on a load-bearing slot (date, service, payment) after one re-prompt. | One re-prompt is the floor; second failure escalates. |
EXPLICIT_REQUEST | Customer message matches talk to a person, mwambie mtu, nataka kuongea na mtu, human please, etc. (intent-classified, not regex — handles typos and code-switching). | Hardcoded human-request intent class with high precision/low recall. False positives are cheap (admin gets paged); false negatives are not. |
TOOL_ERROR_UNRECOVERABLE | M-Pesa STK push returns a terminal error (e.g., subscriber not registered, account locked) that the agent has no recovery path for; or calendar API returns 5xx for >30s. | Distinguish from transient errors that warrant a retry. |
POLICY_TRIPWIRE | Conversation touches a topic outside the agent's allowed scope: refund disputes >KES 5,000, complaints about service quality, medical questions in clinic tenants, anything tagged "legal advice" in legal-consultancy tenants. | Per-tenant configurable list. Sector defaults ship with the tenant template. |
SENTIMENT_NEGATIVE | Sentiment classifier flags strong negative emotion (frustration, anger) for 2 consecutive turns. | Pulled from a small in-context classifier; do not over-tune (false positives here are emotionally costly to customers). |
ADMIN_PULL | Admin proactively types in dashboard or sends a takeover phrase from their phone (e.g., /take or niko hapa). | Admin can grab the conversation before any threshold trips. Always respected immediately. |
BUDGET_BREACH | Conversation has exceeded a configured token/turn budget (e.g., >30 turns or >30k tokens) without reaching a terminal FSM state. | Cost-driven safety valve. Long conversations correlate with confused agents. |
The trigger logic runs at every orchestrator turn, evaluated after the FSM has emitted its draft response but before the AnswerShaper sends to the channel. If any trigger fires, the orchestrator calls interrupt(handoff_payload) instead of dispatching the draft. The draft is preserved in the interrupt payload so the admin can choose to send it as-is.
I'm uncertain about the exact 0.6/0.55 numbers. They are starting points, not science. The methodology calls for treating these as eval-tunable hyperparameters — A3 (eval framework) is the right place to refit them once we have 50 real Swahili conversations to grade against.
3. Context-briefing format
When a trigger fires, the admin needs three things in one WhatsApp message: what the customer wants, what the agent has already collected, and why I'm being paged. Last-N-turns verbatim alone is too noisy (admins are spa owners, not transcript-readers); an LLM summary alone strips the customer's actual words (you can't paraphrase a complaint without losing the complaint's texture). The brief carries both: an LLM-generated structured summary plus the last 5 turns verbatim, with the latter scrollable below a fold.
Who pays for the summary call? The summary LLM call is on Ratiba's per-tenant operations budget, not metered to the customer conversation. Reasoning: the admin handoff is a Ratiba product feature, not a tenant-billable agent turn. If a tenant's pages-per-day exceeds a threshold (TBD in pricing — likely >20/day on a starter plan) the surcharge model handles it at the billing layer, not the FSM layer. The summary is generated by a deliberately small/fast model (Claude Haiku class) using a fixed prompt template — cost ceiling per page is a few cents.
Brief schema (filled by the summary LLM with constrained outputs in strict mode):
{
"trigger": "LOW_CONF_INTENT",
"language": "sw",
"customer_phone_masked": "+254 7** *** 432",
"summary_one_line": "Mteja anataka kufutwa appointment ya kesho — anasema huduma haikuwa nzuri.",
"what_collected": {
"service": "Massage 90 min",
"appointment_date": "2026-04-26 14:00",
"staff": "Grace"
},
"why_paged": "Mteja amejaribu kueleza tatizo mara tatu, sintobaini.",
"suggested_next_action": "Confirm refund policy + offer reschedule.",
"agent_draft_to_send": "Samahani kwa usumbufu — naweza kukuhamisha kwa muda mwingine?"
}
The AnswerShaper renders this into a WhatsApp message with structure (header / one-line summary / collected slots / why-paged / a "show full transcript" button that fetches the last 5 turns from Postgres on demand — keeps the brief message itself short).
Concrete example, English tenant:
RATIBA HANDOFF — Wanjiku's Spa
Customer +254 7** *** 432 · Triggered: LOW_CONF_INTENT
---
Customer wants to cancel tomorrow's appointment — says service was poor.
Already collected:
Service: Massage 90 min
When: Sat 26 Apr, 14:00
Staff: Grace
Why paged: customer tried to explain 3 times, I couldn't grasp it.
Suggested next: confirm refund policy + offer reschedule.
Agent's drafted reply (you can /send to use it):
"Sorry for the inconvenience — can I move you to another time?"
[Show last 5 turns] [/send] [/take] [/done]
Concrete example, Swahili tenant:
RATIBA HANDOFF — Spa ya Wanjiku
Mteja +254 7** *** 432 · Sababu: LOW_CONF_INTENT
---
Mteja anataka kufutwa appointment ya kesho — anasema huduma haikuwa nzuri.
Nimekusanya tayari:
Huduma: Massage 90 min
Lini: Jumamosi 26 Apr, 14:00
Mfanyakazi: Grace
Sababu ya kukuita: mteja amejaribu kueleza mara tatu, sijaelewa.
Pendekezo: thibitisha sera ya marejesho + toa muda mwingine.
Jibu la AI lililoandaliwa (tumia kwa /send):
"Samahani kwa usumbufu — naweza kukuhamisha kwa muda mwingine?"
[Onyesha mazungumzo] [/send] [/take] [/done]
The English/Swahili language of the brief is the admin's preferred language, set on the tenant record at onboarding — not the customer's language. The summary model translates the underlying conversation into the admin's language while preserving the customer's verbatim quote in summary_one_line if it's load-bearing (e.g., a complaint). This is one of the two LLM calls per handoff (the other being the resume re-greeting).
4. FSM state additions
The booking FSM (per A1) is currently single-driver: the agent drives, full stop. Handoff introduces a second driver. The cleanest model is to make the driver an explicit dimension of FSM state, orthogonal to the booking-stage states.
New state-dimension: driver (one of AGENT, HUMAN, SUSPENDED_FOR_HUMAN).
Three first-class states added to the orchestrator state graph (these are LangGraph nodes/edges, not booking-stage states):
| State | Meaning | Driver | What the orchestrator does |
|---|---|---|---|
AGENT_DRIVING | Default. Agent owns the conversation. | AGENT | Normal turn loop: receive message → FSM transition → emit draft → AnswerShaper → send. |
SUSPENDED_FOR_HUMAN | interrupt() called; checkpoint persisted; admin notified; awaiting admin engagement. | none (transitional) | No outbound messages from the agent. Inbound customer messages are queued (Redis list keyed by thread_id) and shown to the admin as "customer is still typing" indicators. |
HUMAN_DRIVING | Admin has acknowledged and is actively conversing with customer. | HUMAN | Agent is a passive transcript recorder. Every customer message + every admin message is appended to the FSM's conversation log. No FSM transitions, no LLM calls. The Redis queue from SUSPENDED_FOR_HUMAN is drained into the live admin thread on entry. |
RESUMED_BY_AGENT | Admin has signalled hand-back; agent re-greets and the orchestrator resumes the FSM. | AGENT (just-resumed) | One-shot transitional state: emit a short reorientation message ("Karibu tena! Niko hapa kuendelea — ulisema unataka kuhamisha kwa Jumanne, sivyo?"), then transition to whatever booking-stage the FSM was in pre-suspension, possibly with slot revisions from the admin's resolution. |
Transition table:
| From | To | Trigger |
|---|---|---|
AGENT_DRIVING | SUSPENDED_FOR_HUMAN | Any of the §2 confidence triggers fires. |
SUSPENDED_FOR_HUMAN | HUMAN_DRIVING | Admin sends /take (or first message in the thread that is identified as coming from the admin's registered DID). Tracked by Redis flag ratiba:handoff:{thread_id}:status = "engaged". |
SUSPENDED_FOR_HUMAN | RESUMED_BY_AGENT | Admin sends /dismiss (page acknowledged but agent should continue). Rare path; the brief was sent, admin says "you're fine, keep going." |
SUSPENDED_FOR_HUMAN | HUMAN_DRIVING (auto) | Timeout: admin hasn't responded in 90s. Shifts to "we're trying" mode where the agent sends the customer a one-time "Tafadhali subiri kidogo, naita meneja" message and stays suspended; admin keeps getting reminder pings every 5 min for up to 30 min before the page escalates to a fallback admin or, in single-admin tenants, becomes a callback-tomorrow apology. |
HUMAN_DRIVING | RESUMED_BY_AGENT | Admin sends magic phrase (/done, rudisha kwa AI, agent take over, umalize wewe) or clicks dashboard "Hand back" button. |
HUMAN_DRIVING | HUMAN_DRIVING (terminal) | Admin sends /end or funga — admin has fully resolved and the conversation closes; agent never reawakens. The thread state goes to CLOSED_BY_HUMAN. |
RESUMED_BY_AGENT | AGENT_DRIVING | After the reorientation message is sent. |
Composition with the booking FSM from A1. The booking FSM (greet → identify → service → slot → confirm → pay) lives inside the AGENT_DRIVING driver-state. When the orchestrator suspends, the booking FSM's current state and slot dictionary are part of the LangGraph checkpoint payload — they freeze. On resume, they thaw exactly as they were unless the admin's hand-back message includes slot revisions (e.g., admin says "the date is now Tuesday" — the resume Command carries {"slot_updates": {"appointment_date": "2026-04-29"}} and the FSM applies them before the reorientation message). This is exactly the LangGraph Command(resume=value) shape — value is arbitrary JSON, the receiving node owns interpretation.
5. Bidirectional WhatsApp threading
The hard problem: one WhatsApp thread, three logical parties (customer, agent, admin), one wire (the customer's phone number ↔ the tenant's WhatsApp DID via 360dialog). The customer must never see "Hi, I'm Wanjiku, the actual owner" undermined by the agent's next message saying "Sawa, ningependa kukusaidia kuweka miadi" half a second later.
The mental model: 360dialog is a single bidirectional pipe; the orchestrator is the dispatcher that owns inbound disambiguation and outbound serialization.
Inbound (messages arriving at the tenant's WhatsApp DID):
- Each inbound message has a
fromfield (the sender's WhatsApp ID). - The orchestrator looks up
fromagainst two registries: (a) the tenant's customer registry, (b) the tenant's admin registry (set by the tenant at onboarding — typically 1-3 admin phone numbers). - If
frommatches an admin: the message is routed to the handoff control plane — possibly a magic phrase, possibly content to forward to the customer, possibly a/takecommand. - If
frommatches a customer (or is unknown — first-time customer): the message is routed to the customer plane — handled by the FSM ifdriver == AGENT, or queued/forwarded to the admin ifdriver == HUMAN.
Outbound (messages the orchestrator sends through 360dialog to the customer's WhatsApp ID):
- When
driver == AGENT: AnswerShaper output goes out as-is, no prefix. - When
driver == HUMAN: the admin types in their own WhatsApp client → message arrives to the orchestrator withfrom = admin_phone→ orchestrator forwards the content unmodified to the customer's number through 360dialog. The customer sees a normal WhatsApp message from the tenant's DID, no prefix, no badge — by design, because to the customer this is just "the spa" replying.
The disambiguation rule that keeps everyone sane: the customer is talking to the tenant's DID, period. They never know whether a given message originated in the agent or the admin. The admin is talking to the tenant's DID via the admin control plane, with WhatsApp commands (/take, /done, /dismiss) and prefix-free content forwarding. There is no third party visible in the thread.
Compliance disclosure interacts here — see §8. The compliance answer is: the first message of a session (the agent's greeting) carries an unobtrusive AI disclosure ("Hujambo! Mimi ni AI ya Wanjiku's Spa…"), and there is no per-handoff disclosure flip — because once you've told the customer they're talking to an AI assistant for a business, a human stepping in is just "the business" doing customer service. This is also the EU AI Act Article 50 reading: the disclosure obligation is satisfied at first interaction, not maintained per-message.
Edge case — admin sends to customer something the FSM needs to know about. When admin says "OK I've moved you to Tuesday at 3pm," the FSM doesn't automatically know the slot changed. Two solutions, in order of preference: (a) the hand-back Command(resume=...) carries explicit slot updates the admin enters via dashboard or /done service=Massage,when=2026-04-29T15:00; (b) on resume, the orchestrator runs an LLM extractor over the human-driving conversation log to extract any slot deltas. (a) is the contract; (b) is the safety net. ADR-0006 should pick one as primary.
Edge case — customer sends a message while suspended but before admin engages. The Redis queue (key ratiba:handoff:{thread_id}:inbound) accumulates these. On admin engagement (transition to HUMAN_DRIVING), the queued messages are drained into the admin's view as a "while you were away…" block. The customer sees no acknowledgement of these messages from the agent during the suspension window — silence is the right behaviour, because the agent has explicitly stepped back.
6. Voice handoff strategies
Voice is materially harder than WhatsApp because there is no "queue while waiting" — silence is awkward, the caller starts to hang up after ~10 seconds. The Phase C §8 voice-stack research and the recent LiveKit handoff-pattern blog converge on three viable patterns.
Pattern 1: 3-way bridge via LiveKit WarmTransferTask (recommended primary for Phase 2).
LiveKit's WarmTransferTask is the production-shaped primitive: caller is placed on hold, a private consultation room is created where the AI briefs the human (with chat_ctx = the conversation context), the human is dialed in via SIP (sip_call_to = admin_phone, sip_trunk_id = ...), the human is moved into the caller's room, and both AI agents disconnect, leaving a clean human-to-human call. Despite the name "3-way bridge" in colloquial use, it is technically sequential single-leg transfers with a briefing room in between, which is operationally cleaner than true 3-way. The human-to-AI hand-back path is documented (the human triggers a transfer back to a LiveKit room where a fresh agent is initialized with the accumulated conversation context).
This is the right primary because it preserves the customer's phone-call experience (one number, one ringing event) and matches the WhatsApp model architecturally (admin engages on the same channel the customer is on).
Pattern 2: Callback-the-customer-back (recommended fallback when admin is unavailable for <60s).
Agent says "Tafadhali subiri, meneja atakupigia simu kwa dakika tano" / "Please wait, the manager will call you back within five minutes," gracefully terminates the call, persists the FSM state with a pending_callback flag, and pages the admin via push notification + WhatsApp brief (same brief format as §3 but with "CALLBACK NEEDED" header). Admin uses the dashboard to trigger an outbound call, which restarts a LiveKit room with the FSM rehydrated from the checkpoint and the admin already in the room.
This is the fallback because (a) latency to admin pickup is bounded by their human response time (no SLA), (b) it doesn't tie up the customer's phone line waiting, and (c) it gracefully degrades when the admin is genuinely offline.
Pattern 3: Deferred follow-up via WhatsApp (graceful degradation only).
When neither bridge nor callback is feasible (admin unavailable for >30 min, after-hours, no admin registered), the agent says "Tutakuandikia WhatsApp mara tu meneja atakapokuwepo" / "We'll WhatsApp you as soon as the manager is available," collects the callback number explicitly if not already known, persists FSM state, and queues a WhatsApp-driven follow-up that runs through the standard WhatsApp handoff lifecycle.
Recommendation: Phase 2 ships with Pattern 1 as primary and Pattern 2 as fallback, with Pattern 3 wired as the floor. Voice handoff in Phase 1 is explicitly out of scope; if a voice call hits a confidence trigger in Phase 1, the agent terminates with Pattern 3. This keeps Phase 1 voice scope narrow without painting us into a corner for Phase 2.
A caveat I am moderately uncertain about: LiveKit's WarmTransferTask is well-documented for English-speaking telecom contexts (US/EU SIP trunks). Whether SIP transfer signalling works cleanly through Kenyan SIP trunks (Africa's Talking, Hormuud, etc.) is something I could not confirm from public sources. The pre-Phase-2 spike for voice handoff should test this end-to-end before locking the design.
7. Re-handoff (admin → agent)
Recommended primary: a magic-phrase command sent from the admin's WhatsApp, with a dashboard button as parallel UX, and a soft timeout as safety net.
The phrase set must be:
- Short (admins type on phones).
- Unambiguous (cannot be triggered by accidental conversation content).
- Bilingual (the admin chose their language at onboarding; both phrasings are accepted regardless to handle code-switching admins).
- Composable with payload (admin can include slot updates).
Recommended phrase set:
| Command | Effect |
|---|---|
/done or umalize (or nimemaliza) | Hand back to agent. Agent re-greets in customer's language and continues from the saved FSM state. |
/done service=X when=Y | Hand back with explicit slot updates to apply before resuming. |
/end or funga | Hand back AND close: admin has fully resolved, agent does not reopen the conversation. |
/take or niko hapa | Take over (only meaningful when driver != HUMAN — explicit grab even without a trigger). |
/dismiss or endelea | Dismiss the page, agent should continue from where it was suspended. Used when the brief makes clear the agent had it right. |
The slash prefix is a deliberate convention. It makes commands distinguishable from content the admin wants forwarded to the customer. Without a slash, admin text is treated as customer-facing content. (We considered using natural-language detection of intent — "I'm done, take it back, agent" — but rejected: too easy to misclassify, and the admin is the operator of the system, not the customer; we can ask them to learn five commands.)
Dashboard button parallel UX: the dashboard's live-conversations view has a per-thread "Hand back to agent" button with optional slot-update form. Functionally identical to /done, exists for admins who prefer GUI. The action POSTs to /api/handoff/{thread_id}/handback which calls Command(resume=...) on the LangGraph thread.
Soft timeout safety net: if driver == HUMAN and there has been no admin message for 15 minutes, the system DMs the admin "Bado uko kazini? Mteja anasubiri." / "Still active? Customer is waiting." If 30 min pass with no admin activity and the customer has sent a message in that window, the system either (a) hands back to the agent automatically with a "Samahani kwa kuchelewa, hebu nikusaidie…" / "Sorry for the delay, let me help…" reorientation, or (b) closes the thread with an apology and a callback request. Recommend (a) by default, configurable per tenant.
The reorientation message when transitioning RESUMED_BY_AGENT → AGENT_DRIVING: this is the single most UX-sensitive output in the whole handoff lifecycle. A bad reorientation breaks the customer's trust ("the human disappeared and the bot is back, has it forgotten everything?"). The reorientation must:
- Acknowledge the human conversation happened ("Asante kwa kuongea na meneja" / "Thanks for chatting with the manager").
- State explicitly what state we are in ("Tunaendelea kuweka miadi yako ya Massage Jumanne saa tisa, sivyo?" / "Continuing your booking for Massage on Tuesday at 3pm, right?").
- Ask one small confirmation question that lets the customer say "yes, continue" or "wait, the human said something different."
The reorientation prompt is the second of the two LLM calls per handoff. It runs against a fresh slot snapshot (post any admin updates) and the most recent 3 turns of the human-driving log, so the reorientation can reference what the admin actually told the customer.
8. Compliance disclosure
Three regulatory regimes plausibly apply to Ratiba's handoff model. None of them yet has settled enforcement case law for "AI hands off to human in a single conversation," so the recommendations below are conservative-by-design.
1. Kenya — Data Protection Act 2019 + AI Bill 2026.
The Data Protection Act 2019 is the operative regime today. It is GDPR-shaped: lawful basis, data minimization, transparency. Sector-relevant clauses for Ratiba are around special-category data (health data — relevant for clinic/dental/physio tenants), purpose limitation, and cross-border transfer (relevant if any LLM call leaves Kenya, which it does with Anthropic / Deepgram / ElevenLabs).
Kenya's Artificial Intelligence Bill 2026 (introduced March 2026, not yet enacted as of this writing) adds explicit AI-disclosure obligations: for "high-risk AI systems," users must be notified of the nature, purpose, and limitations of the system, the extent of automated decision-making, and the bias-mitigation measures in place. A booking agent for a spa is unlikely to be classified high-risk; a triage agent for a dental clinic plausibly is. The bill is not law yet, but the trajectory is clear: design the disclosure surface now, light-touch by default, expandable per-tenant.
2. EU AI Act — Article 50 (effective 2 August 2026).
Any tenant whose customers include EU residents triggers Article 50: chatbots that interact with natural persons must inform them they are interacting with an AI system, in a clear and distinguishable manner, at the latest at the time of first interaction. The disclosure obligation is satisfied once per interaction, not per message, and is waived only if AI-ness is "evident to a reasonably well-informed, observant, and cautious person." For a WhatsApp business agent, AI-ness is not evident — many people assume they are messaging a human at a small business. So we owe the disclosure.
For Ratiba's market this is an edge case (Kenyan SMBs serving Kenyan customers), but a Phase 2 expansion to East African diaspora clients in EU jurisdictions or to Kenyan tenants serving EU tourists puts us inside Article 50.
3. Sector-specific health regulations.
For clinic / dental / physio tenants, we should treat any AI exchange that involves symptoms, medications, or diagnoses as triggering the same disclosure standard as a high-risk system regardless of which territory's law applies. This is also the operationally safer position: the cost of disclosure is one extra message per session; the cost of a malpractice-adjacent claim is existential.
Recommended default disclosure phrasing (sent as the first agent message of any session, before any FSM transition):
- English: "Hi! I'm Ratiba, the AI assistant for [Tenant Name]. I can help you book, change, or pay for appointments. If anything's unclear, I'll connect you with a real person."
- Swahili: "Hujambo! Mimi ni Ratiba, msaidizi wa AI wa [Jina la Biashara]. Naweza kukusaidia kuweka, kubadilisha, au kulipia miadi. Kama kuna jambo lisiloeleweka, nitakuunganisha na mtu halisi."
Three properties of this phrasing matter:
- The word "AI" appears explicitly. No euphemism ("smart assistant," "automated helper") — those have been called insufficient under EU Article 50 draft guidance.
- The handoff promise ("connect you with a real person") is part of the disclosure. This sets the customer's expectation that humans are reachable, which is operationally what we deliver.
- No legalese. The disclosure is one sentence and feels like a greeting, not a click-through.
Where the disclosure does NOT need to repeat:
- Per-turn (would be intolerable).
- At handoff (the customer was told this could happen in the greeting).
- At hand-back (same logic — this is "the business" replying, with whoever happens to be on duty).
Where the disclosure DOES need to repeat:
- New sessions after a long quiet period (heuristic: >72h since last message in the thread). New session, new greeting, new disclosure.
- Voice calls — the spoken equivalent of the disclosure is the agent's first utterance. "Hujambo, mimi ni Ratiba…" / "Hi, this is Ratiba…" Cannot be skipped even if the customer has interacted via WhatsApp before, because voice is a different channel and the disclosure must be in-channel.
An explicit non-recommendation: do not flip a "you are now talking to a human" indicator at the moment of handoff. The customer is talking to the tenant's DID throughout. The agent vs admin distinction is a Ratiba-internal implementation detail, not a customer-facing distinction. Showing a "now with human" badge would also create a "now back to AI" badge problem at hand-back, which would erode trust ("the human gave up, I'm being palmed off"). Cleaner to keep the seam invisible — the disclosure at greeting handles the legal obligation; the operational handoff is a tenant-side workflow concern.
ADR-0006 should explicitly resolve the EU-resident-customer policy question: do we (a) show the EU-AI-Act-grade disclosure to all customers (overkill but simple), or (b) detect EU customer phone numbers and conditionally show a stronger disclosure to that subset (correct but adds complexity)? Recommend (a) — the disclosure is short, the cost of universalizing it is zero.
9. LangGraph interrupt-and-resume integration
The TenantScopedSaver wrapper from the spike is the only thing standing between the LangGraph standard interrupt/resume primitives and the multi-tenant production path. The handoff lifecycle maps directly onto LangGraph's primitives — we do not extend or replace them, we use them as-shipped through the wrapper.
The mapping (each lifecycle step → LangGraph primitive):
| Lifecycle step | LangGraph mechanism |
|---|---|
| Suspend agent | Orchestrator node calls interrupt(handoff_payload). LangGraph persists graph state via TenantScopedSaver against thread_id (which equals our WhatsApp conversation ID). |
| Notify admin | Caller of graph.invoke(...) receives interrupt payload via the __interrupt__ key. The caller is our orchestrator's outer loop — it dispatches the brief to the admin via 360dialog. |
| Wait for admin engagement | LangGraph thread is now in interrupted state. No graph code is running. Resources idle until resume. State lives indefinitely in the tenant's checkpoint schema. |
| Admin engages → admin↔customer message-passing | Happens entirely outside the graph. Customer messages and admin messages are appended to a Postgres handoff_log table keyed by thread_id. Graph remains paused throughout. |
| Admin signals hand-back | Orchestrator's outer loop detects the magic phrase / dashboard event, gathers any slot updates from the handoff log, and calls graph.invoke(Command(resume={...}), config={"configurable": {"thread_id": thread_id, "tenant_id": tenant_id}}) through the TenantScopedSaver-bound graph. |
| Resume → reorientation → continue | The interrupted node receives the resume value as the return of its interrupt() call. The node applies any slot updates, emits the reorientation message, and continues to the next node in the booking FSM. |
Code-shape pseudocode (illustrative, not runnable — types are sloppy on purpose):
# backend/app/orchestration/handoff_node.py
from langgraph.types import interrupt, Command
def handoff_check_node(state: BookingState) -> Command | dict:
"""Runs after every agent turn before the AnswerShaper dispatches.
If a confidence trigger fires, suspend; else pass through."""
triggers = evaluate_triggers(state)
if not triggers:
return {"draft_to_send": state.draft_response}
# Build the brief payload (this is what the admin sees)
brief = build_admin_brief(state, triggers) # uses summary LLM
handoff_payload = {
"trigger": triggers[0].code,
"brief": brief,
"agent_draft": state.draft_response,
"fsm_snapshot": state.fsm_dict(),
"thread_id": state.thread_id,
}
# 1. Persist the brief externally so the admin's WhatsApp dispatcher can pick it up.
enqueue_admin_brief(state.tenant_id, state.thread_id, brief)
# 2. Interrupt — LangGraph saves checkpoint via TenantScopedSaver,
# returns control to graph.invoke caller.
resume_value = interrupt(handoff_payload)
# 3. We get here only when admin has signalled hand-back.
# resume_value carries the admin's resolution.
if resume_value.get("action") == "close":
return Command(goto="closed_by_human", update={
"driver": "AGENT",
"outcome": "human_resolved",
})
return Command(goto="reorient_node", update={
"driver": "AGENT",
"slot_updates": resume_value.get("slot_updates", {}),
"human_log_summary": resume_value.get("admin_summary", ""),
})
# backend/app/orchestration/reorient_node.py
def reorient_node(state: BookingState) -> dict:
"""One-shot node that re-greets the customer after handback."""
state.apply_slot_updates(state.slot_updates)
reorient_msg = generate_reorientation( # second LLM call per handoff
slots=state.slots,
last_human_turns=state.human_log_summary,
language=state.customer_language,
)
send_to_customer(state.thread_id, reorient_msg)
return {"driver": "AGENT", "stage": state.previous_stage}
# backend/app/persistence/checkpointer.py — the wrapper from the spike
class TenantScopedSaver:
"""Per the PostgresSaver spike (Option A): a per-invocation factory
that opens a psycopg connection with search_path bound to the tenant
schema, then constructs a vanilla PostgresSaver(conn=...)."""
def __init__(self, dsn: str):
self._dsn = dsn
def for_tenant(self, tenant_id: str) -> PostgresSaver:
conn = psycopg.connect(self._dsn, autocommit=True, ...)
schema = f"tenant_{tenant_id}"
with conn.cursor() as cur:
cur.execute(f"SET search_path TO {schema}, public")
return PostgresSaver(conn=conn)
# backend/app/orchestration/runner.py
async def run_turn(tenant_id: str, thread_id: str, customer_msg: str):
saver = checkpointer_factory.for_tenant(tenant_id)
graph = booking_graph.compile(checkpointer=saver)
config = {"configurable": {"thread_id": thread_id, "tenant_id": tenant_id}}
state_in = build_state_from_inbound(customer_msg)
return await graph.ainvoke(state_in, config=config)
async def resume_after_handback(tenant_id: str, thread_id: str, admin_resolution: dict):
saver = checkpointer_factory.for_tenant(tenant_id)
graph = booking_graph.compile(checkpointer=saver)
config = {"configurable": {"thread_id": thread_id, "tenant_id": tenant_id}}
return await graph.ainvoke(Command(resume=admin_resolution), config=config)
Two things to note explicitly:
-
The interrupt payload must be JSON-serializable. The LangGraph docs warn against passing class instances or callables. Our
handoff_payloadis a plain dict of strings/dicts/ints, which is safe. Thefsm_snapshotfield usesstate.fsm_dict()rather than the state object itself. -
The
thread_idin our config equals the WhatsApp conversation thread identifier (per-customer-per-tenant). This is load-bearing: the samethread_idMUST be used on resume, because that is how LangGraph rehydrates the checkpoint. We will key it asf"{tenant_id}:{customer_phone_e164}"to avoid any accidental cross-tenant collision even though the checkpoint table is already in a tenant-isolated schema.
Failure modes we must explicitly handle in the orchestrator outer loop:
- Backend restart while suspended. No-op. The checkpoint is durable in Postgres; resume from any process. This is a feature of the design.
- Admin sends
/donebut the thread is not actually suspended (race condition with another resume in flight). Idempotency: the resume call should detect that the thread is no longer interrupted and respond gracefully ("the agent has already taken back, no action needed"). - Resume call fails mid-graph (e.g., LLM API outage). The interrupt checkpoint is still in place; LangGraph will not consume it until the resume succeeds. Retry with exponential backoff in the orchestrator outer loop.
10. Open questions for ADR-0006 to resolve
The following are decisions that emerged during this research and should be explicitly resolved (with chosen-and-rejected reasoning) in ADR-0006.
-
Slot-update channel — explicit
/done service=X when=Ysyntax vs. LLM extraction over the human-driving log. Recommendation in §5 is "explicit primary, extraction as safety net," but ADR-0006 should pick one of (a) explicit-only-strict (any unparsed/donetriggers a clarification prompt to the admin), (b) extraction-only (more "magical" but error-prone), (c) explicit-primary with extraction fallback (recommended here, but adds two code paths to maintain). -
Page-then-no-engagement timeout policy. §4 uses 90s before the system tells the customer "naita meneja." That number is a guess. ADR-0006 should pick an initial value and commit it to eval-driven refinement.
-
Multi-admin tenants — paging strategy. A spa with two co-owners both registered as admins: does a page go to (a) all admins simultaneously, first-to-engage takes the conversation; (b) round-robin; (c) primary-then-fallback? Phase C did not surface a clear best practice here. Recommend (a) with explicit "claimed by Wanjiku" indicator to other admins.
-
EU-resident-customer disclosure scope. §8 recommends universalizing the EU-Article-50-grade disclosure for simplicity. ADR-0006 should formally accept or reject this.
-
Voice-handoff Phase 1 fallback. §6 recommends "Pattern 3 only" in Phase 1 (i.e., voice calls that hit a confidence trigger terminate with a WhatsApp follow-up promise). ADR-0006 should commit to this as a Phase 1 constraint, deferring Pattern 1 fully to Phase 2.
-
Brief language vs. customer language. §3 specifies the brief is in the admin's preferred language. What about the verbatim transcript fold? Recommend: customer's actual messages preserved verbatim regardless of admin language; admin can request a translation by tapping a button. ADR-0006 should confirm.
-
Dashboard handoff queue — separate inbox or in-line with normal conversations? Phase C did not cover this. Suggest a separate "Handoff inbox" view in the dashboard with a count badge, plus the conversation visible in-line. ADR-0006 should specify the UX shape so the frontend ADR (M3) can implement.
-
Interaction with M-Pesa STK push in flight. If a confidence trigger fires after an STK push has been dispatched but before the callback arrives, what happens? Recommendation: STK push is its own state outside the suspend/resume lifecycle; payment callbacks update the FSM directly via a separate webhook handler that does not require the agent to be active. But this needs explicit confirmation that the LangGraph checkpoint can absorb out-of-band state mutations applied while the thread is interrupted.
-
Per-tenant trigger threshold tuning. §2 hardcodes 0.6/0.55 confidence thresholds. Should these be per-tenant configurable from day one (some sectors will want lower thresholds — e.g., dental clinics — and some higher — barbershops)? Recommend: tenant-configurable with sensible per-sector defaults. ADR-0006 should commit to the defaults table.
-
LangGraph checkpoint cleanup for closed handoffs. When a thread terminates with
CLOSED_BY_HUMAN, do we delete the LangGraph checkpoint or retain it for audit / dispute resolution? Recommend retain for 90 days then archive. ADR-0006 should specify retention policy. -
Audit log for admin-driving turns. Every message exchanged in
HUMAN_DRIVINGmode is sent over WhatsApp by a real person, but it lives in our system as a record. Compliance-wise (Kenya DPA, future health data regs), is this storage of admin-typed content covered by our existing privacy policy? ADR-0006 should flag this for the Phase 1 privacy policy review even though the answer probably lands in a separate compliance ADR. -
Failure of the page itself (admin's WhatsApp is offline / wrong number). Currently §6 timeout escalates to fallback admin or callback-tomorrow. But the page-delivery layer (360dialog → admin's phone) can itself fail silently (e.g., admin has disabled WhatsApp notifications). What is our SLO on "admin was paged but did not see it"? Recommend: 360dialog read-receipt monitoring with escalation to SMS/voice-call after 2 min unread. Out of scope for ADR-0006 itself, but a candidate sub-ADR.
Sources
- LangGraph Interrupts (docs)
- LangChain blog — Making it easier to build human-in-the-loop agents with interrupt
- DEV — Interrupts and Commands in LangGraph: Building HITL Workflows
- BSWEN — How to Implement HITL in LangGraph Using interrupt() (2026)
- LiveKit — The Handoff Pattern for Voice Agents That Replaces IVR Menus
- LiveKit — Sequential pipeline architecture for voice agents
- LiveKit — Telephony / agents integration docs
- Article 50 — EU AI Act (Transparency Obligations)
- EU AI Act Service Desk — Article 50 transparency obligations
- Kenya Data Protection Act 2019 — Kenya Law full text
- Cliffe Dekker Hofmeyr — Kenya AI Bill 2026 summary
- HapaKenya — Summary of the Kenya AI Bill 2026
- Securiti — Kenya DPA Compliance Guide
- respond.io — WhatsApp AI Agent: Capabilities & Setup
- Omnichat — WhatsApp Business API for CX in 2026
- Companion artefacts (in-repo):
docs/research/2026-04-25-agentic-landscape-2026.md,docs/research/2026-04-25-langgraph-postgressaver-spike.md,docs/adr/ADR-0001-tech-stack.md