Admin orchestrator
What it does
The admin orchestrator is a dual-channel admin rail — a dashboard
WebSocket pushed via Postgres LISTEN/NOTIFY plus per-tenant opt-in
WhatsApp inbound — through which the human owner reclaims a customer
conversation when the AI loses confidence.
Admin FSM
AdminOrchestrator is a shallow 4-state machine: IDLE and ENGAGED,
each with two sub-states (IDLE_FREE / IDLE_AVAILABLE and
ENGAGED_ACTIVE / ENGAGED_CONFIRMING). The active state is stored in
conversation_threads.actor_type and carries a 4-hour TTL swept by
the sweep_stale_admin_engagements() cron at boot. When the TTL fires
without a /handback, the system automatically transitions the thread back
to customer so the AI can resume — no silent stuck states.
The full conversation FSM behind the customer booking flow is separate;
see Conversation FSM for how
actor_type is read at the booking-graph boundary.
Handoff triggers (d1–d5)
Handoff follows Pattern 3 — WhatsApp follow-up only, as locked by ADR-0006. A trigger fires when the booking FSM hits one of five signals:
| Signal | Description |
|---|---|
| d1 | Intent classifier confidence below the per-tenant floor (default: 3 consecutive sub-threshold turns) |
| d2 | Two consecutive negative-sentiment turns |
| d3 | Customer explicitly requests a human ("talk to someone") |
| d4 | PII paste detected (national ID, credit card pattern) |
| d5 | Compliance keyword (e.g. medical emergency, threat) |
The 3-signal classifier failure budget is 2%/5% per-tenant (ADR-0005): if d1 fires on more than 2% of turns in a rolling window, an alert surfaces in the dashboard. At 5% the tenant's intent model is flagged for retraining.
STK-in-flight = callback always authoritative. If a Daraja STK push is in flight when handoff fires, the admin's briefing card says "Payment in progress — callback is authoritative." The admin cannot double-drive a payment; the FSM suspends until the callback completes. See Payments for the full payment FSM.
Timing
Per-tenant configurable via handoff_thresholds JSONB. Platform defaults:
| Checkpoint | Default | Description |
|---|---|---|
| First nudge | 120 s | Re-notify admin via WhatsApp if no acknowledgement |
| Escalate | 10 min | Mark the thread as unacknowledged in the dashboard |
| Abandon | 60 min | Auto-handback to customer with a "back with the AI" message |
Voice handoff is Pattern 3 with a callback-tomorrow fallback when no WhatsApp is on file. For the voice-specific flow, see Voice conversation.
Briefing card
When a handoff fires, _admin_fanout() simultaneously:
- Writes a row to
<tenant>.handoff_logwith the trigger signal, the verbatim customer transcript, and a pre-rendered briefing JSONB. - Fires
pg_notify(admin_briefing_<schema>)— the dashboard WS listener surfaces the card instantly. - If
admin_whatsapp_briefing_enabled = TRUE, sends a WhatsApp message to the registered admin phone with a compact card summary.
The briefing card JSON schema is locked in ADR-0006 and includes:
trigger_signal, transcript_verbatim, booking_context, customer_id,
thread_id, and a translation_available flag. The dashboard renders an
on-demand translation button; the translation call is lazy (not pre-baked) to
avoid unnecessary LLM cost on bilingual admin teams.
Retention: <tenant>.handoff_log (live) and <tenant>.handoff_log_archive
(swept by the daily 3 AM EAT reaper) both observe symmetric 90-day
retention per ADR-0006. Log reading → see Observability.
Slash commands
The rail exposes 11 admin slash commands — M9 shipped /today, /cancel,
/reschedule, /handback, /?-help; M11 added /add-service,
/update-price, /update-duration, /toggle-service, /list-services,
/stats.
The four mutation verbs (/add-service, /update-price, /update-duration,
/toggle-service) are gated by a two-turn YES/NO confirmation stored in
Redis with a 5-min TTL keyed by (tenant_id, admin_id, verb) — same admin,
different tenant, different verb all hold independent pending entries. This
means a second admin on the same tenant does not inherit the first admin's
pending confirmation.
Every mutation — slash or NL-recursed — records a row in <tenant>.catalog_audits
with source='slash' and the full parsed payload. For configuration of
per-tenant thresholds, see Configuration.
No typed identifiers
Admins never type or recall a booking id. /today renders a numbered
list (with a deterministic start_at, id order so ordinals are stable),
and /cancel <n> / /reschedule <n> <time> resolve that number against
the same list (a raw UUID is still accepted for back-compat). /help is a
first-class capability menu, unrecognised commands guide rather than scold,
bare /stats defaults to today, and money renders as KES 4,500.
Cancel/reschedule confirmations name the booking and don't claim a
best-effort notification was delivered.
The dashboard chrome brands itself with the tenant's own business name
(via GET /api/v1/admin/profile), not "Ratiba".
Natural-language fallback
The NL fallback (M11 T11) classifies free-form admin text via the LLMRouter
tool layer into three confidence bands:
| Band | Action |
|---|---|
>= 0.9 | Synthesise a canonical slash command; execute immediately |
0.7–0.9 | Reply "I think you mean /update-price Manicure 600 — YES?" and store nl_pending in Redis (5-min TTL) |
< 0.7 | Graceful unknown: "I didn't understand that. Try /?-help for a list of commands." |
The nl_pending lifecycle:
store_nl_pending()writes the synthesised slash string to Redis.consume_nl_pending()reads and deletes it atomically onYES.- If the admin replies
NOor the TTL expires, the pending entry is discarded.
NL synthesis goes through synthesise_slash_command(), which produces a
canonical slash string from the NL intent. This string then flows through the
same handle_admin_command() path as a direct slash, so audit trail and
confirmation gating are identical.
How it fits in the system
How it flows
Handoff and handback (both audiences)
The most common path: a low-confidence turn fires d1, the admin gets a
briefing card, replies /handback, and the FSM resumes.
NL command with mid-confidence clarification
4h TTL stale-engagement reaper
Where it lives in code
| Concern | File | Key entry point |
|---|---|---|
| Slash command dispatcher | app/admin/commands.py | handle_admin_command() (L181) |
| Pending-confirmation key | app/admin/commands.py | _pending_confirmation_key() (L122) |
| Confirmation finaliser | app/admin/commands.py | _finalise_pending_confirmation() (L1429) |
| NL routing decision | app/admin/nl_router.py | route_natural_language() (L167) |
| Slash + NL dispatcher | app/admin/nl_router.py | dispatch_admin_text() (L375) |
| Slash synthesis | app/admin/nl_router.py | synthesise_slash_command() (L236) |
| NL pending lifecycle | app/admin/nl_router.py | store_nl_pending() / consume_nl_pending() (L336/L350) |
| Admin FSM | app/admin/orchestrator.py | AdminOrchestratorState (L80) / transition_to_engaged() (L126) / transition_to_idle() (L151) |
| Stale-engagement sweep | app/admin/orchestrator.py | sweep_stale_admin_engagements() (L219) |
| Inbound message router | app/admin/message_router.py | route_admin_message() (L119) |
| Stats SQL helpers | app/admin/stats.py | stats_today() (L86) / stats_week() (L121) / stats_most_booked() (L155) / stats_revenue_by_staff() (L194) |
| Briefing card render | app/admin/briefing.py | render_for_dashboard() (L80) / render_for_whatsapp() (L107) |
| Handoff fanout | app/admin/fanout.py | _admin_fanout() (L70) |
Decisions
- ADR-0005 — Orchestration model:
the shallow 4-state
AdminOrchestrator; the 3-signal classifier failure budget (2%/5% per-tenant); theLLMRouterrole-based config used by the NL fallback; the$0.05 soft / $0.20 hardcost ceiling tracked viaBookingState.total_token_cost_usd. - ADR-0006 — Handoff model is the
authoritative source for Pattern 3 (WhatsApp follow-up only),
120s/10-min/60-min conservative timing (per-tenant configurable
via
handoff_thresholdsJSONB), STK-in-flight = callback always authoritative, verbatim transcript fold + on-demand translation, symmetric 90-day retention withhandoff_log_archive, and the briefing card JSON schema (locked authoritatively in the ADR). - ADR-0010 — Tenant customisation
D8 is the authoritative source for the 11 admin slash commands
(M9:
/today,/cancel,/reschedule,/handback,/?-help; M11:/add-service,/update-price,/update-duration,/toggle-service,/list-services,/stats); two-turn YES/NO confirmation for the four mutation verbs (Redis 5-min TTL keyed by(tenant_id, admin_id, verb)); NL fallback's three confidence bands (>= 0.9execute /0.7-0.9clarify /< 0.7graceful unknown); audit-trail invariant on<tenant>.catalog_auditswithsource='slash'for all mutations.
Try this on local dev
-
Trigger a handoff. Boot the stack (
docker compose up -d), start a WhatsApp booking against the test tenant, send a vague message ("hmm something something") to push the classifier below the d1 confidence floor; watchstructlogforadmin.fanout.completeand confirm the briefing-card payload landed in the dashboard WS (websocat ws://localhost:8010/admin/ws) plus, ifadmin_whatsapp_briefing_enabled=TRUE, on the registered admin's WhatsApp. -
Reply from the admin's WhatsApp with
/handbackto resume the customer thread, or with a free-form NL command like "what's on for today?" — the NL router will classify>= 0.9and recurse through/today. Try a mid-confidence prompt ("I want to change the haircut price") to see theawaiting_confirmation=Trueclarification round-trip. -
Verify the audit row.
psql postgresql://...:5434 -c "SELECT verb, source, payload, created_at FROM <tenant_schema>.catalog_audits ORDER BY created_at DESC LIMIT 5"— every mutation (slash or NL-recursed) appears withsource='slash', the parsed payload, and the admin user-id. -
Test the 4h TTL. In psql, manually set
engaged_atto 5 hours ago on an active admin thread:UPDATE <tenant>.conversation_threads SET engaged_at = now() - interval '5 hours' WHERE actor_type = 'admin'. Restart the service (or wait for the next hourly sweep) and confirmactor_typeflips back to'customer'and the customer receives a handback message. -
Confirm retention archiving. Late handoff rows past the 90-day cliff land in
handoff_log_archivevia the daily 3 AM EAT reaper. To test without waiting 90 days, updatecreated_aton a test row to 91 days ago and trigger the reaper manually viaPOST /api/v1/internal/run-reaper(dev only). See Observability for reading the reaper logs.
Related
- Conversation FSM — the customer-side
FSM that reads
actor_typeand defers turns whenENGAGED. - Voice conversation — voice handoff (Pattern 3 + callback-tomorrow fallback for no-WhatsApp callers).
- Payments — STK-in-flight behaviour during handoff.
- Catalog onboarding — the
/add-service,/update-price,/update-duration,/toggle-servicecommands write to the sameservicestable managed by the import pipeline. - Observability — reading
admin.fanout.*,admin.command.*, andhandoff_log_archivestructured log events. - Configuration —
handoff_thresholdsJSONB and other per-tenant tunable thresholds. - ADR-0005 — Orchestration model
- ADR-0006 — Handoff model