Skip to main content

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:

SignalDescription
d1Intent classifier confidence below the per-tenant floor (default: 3 consecutive sub-threshold turns)
d2Two consecutive negative-sentiment turns
d3Customer explicitly requests a human ("talk to someone")
d4PII paste detected (national ID, credit card pattern)
d5Compliance 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:

CheckpointDefaultDescription
First nudge120 sRe-notify admin via WhatsApp if no acknowledgement
Escalate10 minMark the thread as unacknowledged in the dashboard
Abandon60 minAuto-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:

  1. Writes a row to <tenant>.handoff_log with the trigger signal, the verbatim customer transcript, and a pre-rendered briefing JSONB.
  2. Fires pg_notify(admin_briefing_<schema>) — the dashboard WS listener surfaces the card instantly.
  3. 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:

BandAction
>= 0.9Synthesise a canonical slash command; execute immediately
0.7–0.9Reply "I think you mean /update-price Manicure 600 — YES?" and store nl_pending in Redis (5-min TTL)
< 0.7Graceful 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 on YES.
  • If the admin replies NO or 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

ConcernFileKey entry point
Slash command dispatcherapp/admin/commands.pyhandle_admin_command() (L181)
Pending-confirmation keyapp/admin/commands.py_pending_confirmation_key() (L122)
Confirmation finaliserapp/admin/commands.py_finalise_pending_confirmation() (L1429)
NL routing decisionapp/admin/nl_router.pyroute_natural_language() (L167)
Slash + NL dispatcherapp/admin/nl_router.pydispatch_admin_text() (L375)
Slash synthesisapp/admin/nl_router.pysynthesise_slash_command() (L236)
NL pending lifecycleapp/admin/nl_router.pystore_nl_pending() / consume_nl_pending() (L336/L350)
Admin FSMapp/admin/orchestrator.pyAdminOrchestratorState (L80) / transition_to_engaged() (L126) / transition_to_idle() (L151)
Stale-engagement sweepapp/admin/orchestrator.pysweep_stale_admin_engagements() (L219)
Inbound message routerapp/admin/message_router.pyroute_admin_message() (L119)
Stats SQL helpersapp/admin/stats.pystats_today() (L86) / stats_week() (L121) / stats_most_booked() (L155) / stats_revenue_by_staff() (L194)
Briefing card renderapp/admin/briefing.pyrender_for_dashboard() (L80) / render_for_whatsapp() (L107)
Handoff fanoutapp/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); the LLMRouter role-based config used by the NL fallback; the $0.05 soft / $0.20 hard cost ceiling tracked via BookingState.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_thresholds JSONB), STK-in-flight = callback always authoritative, verbatim transcript fold + on-demand translation, symmetric 90-day retention with handoff_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.9 execute / 0.7-0.9 clarify / < 0.7 graceful unknown); audit-trail invariant on <tenant>.catalog_audits with source='slash' for all mutations.

Try this on local dev

  1. 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; watch structlog for admin.fanout.complete and confirm the briefing-card payload landed in the dashboard WS (websocat ws://localhost:8010/admin/ws) plus, if admin_whatsapp_briefing_enabled=TRUE, on the registered admin's WhatsApp.

  2. Reply from the admin's WhatsApp with /handback to resume the customer thread, or with a free-form NL command like "what's on for today?" — the NL router will classify >= 0.9 and recurse through /today. Try a mid-confidence prompt ("I want to change the haircut price") to see the awaiting_confirmation=True clarification round-trip.

  3. 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 with source='slash', the parsed payload, and the admin user-id.

  4. Test the 4h TTL. In psql, manually set engaged_at to 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 confirm actor_type flips back to 'customer' and the customer receives a handback message.

  5. Confirm retention archiving. Late handoff rows past the 90-day cliff land in handoff_log_archive via the daily 3 AM EAT reaper. To test without waiting 90 days, update created_at on a test row to 91 days ago and trigger the reaper manually via POST /api/v1/internal/run-reaper (dev only). See Observability for reading the reaper logs.