NoSFeR88/citabot-demo

WhatsApp booking agent demo (portfolio) — mock LLM, SQLite, multi-tenant isolation, guardrails

Python

0

1 commits

updated Sep 28, 2026

See the code

README

CitaBot Demo — WhatsApp Booking Agent

A minimal, self-contained demo of a WhatsApp-style appointment booking agent, built to show the design of a production system like this one — not a copy of any real codebase. It runs with zero setup (no API key, no network, no external services) and every action is backed by a test.

This is a portfolio/demo project by Oliver Mediavilla, built to show how I'd design a booking agent for a small business (a vet clinic, a physio studio, ...) that talks to customers over WhatsApp. It is not a copy of any client's production system — it is a new, minimal implementation of the same ideas.

What it does

  1. A customer sends free text: "¿tenéis hueco el jueves por la tarde para vacunar a mi perro?"
  2. The agent extracts intent and slots (service, day, time of day) via a pluggable "tool calling" boundary.
  3. It checks real availability in a simulated calendar (SQLite) and proposes a slot — it never books anything until the customer explicitly confirms.
  4. Ambiguous messages, complaints, urgencies, or low-confidence extractions are routed to a human handoff queue instead of being guessed at.
  5. Three fictional businesses ("Clínica Veterinaria Ejemplo", "Fisio Demo" and "Centro de Estética Demo") share the same database but their data is fully isolated by tenant_id, and each keeps its own service catalog, prices and weekly schedule.

Flow diagram

flowchart TD
    A[Customer message] --> B{LLM provider<br/>extract intent + slots}
    B -->|complaint / urgent / low confidence| H[Human handoff queue]
    B -->|book| C[Find service by keyword]
    B -->|reschedule / cancel| D[Find existing appointment]
    B -->|confirm / deny reply| P{Pending proposal<br/>for this phone?}

    C -->|not found| H
    C -->|found| E[Validate: future date,<br/>business hours, day open]
    D --> E

    E -->|invalid| R1[Reply: explain why, ask again]
    E -->|valid| F[Query available slots<br/>in SQLite calendar]

    F -->|none free| R2[Reply: no availability,<br/>suggest another day]
    F -->|slots found| G[Propose a slot,<br/>store as pending]

    P -->|no pending| R3[Reply: nothing to confirm]
    P -->|deny| R4[Clear pending, reply: no changes made]
    P -->|confirm| I[Re-validate against calendar<br/>right before acting]

    I -->|guardrail fails<br/>e.g. slot taken meanwhile| R2
    I -->|ok| J[(Book / reschedule / cancel<br/>in SQLite, scoped by tenant_id)]
    J --> K[Reply: confirmation]

Demo businesses

Tenant (--tenant)Business nameHoursServices (fictional prices)
vetClínica Veterinaria EjemploMon-Fri, 09:00-13:00 & 15:00-19:00vacunación (25€), consulta general (35€), peluquería (30€)
fisioFisio DemoMon-Fri, 09:00-13:00 & 15:00-19:00sesión fisioterapia (45€), valoración inicial (20€), masaje deportivo (40€)
esteticaCentro de Estética DemoTue-Sat, 10:00-14:00 & 16:00-20:00limpieza facial (38€), depilación láser -zona- (50€), manicura semipermanente (22€), masaje (42€)

All three share the same SQLite database and are isolated by tenant_id (see tests/test_isolation.py). estetica was added specifically to prove that per-tenant configuration (schedule, catalog, prices) is not a global constant either — see "Design decisions" below.

Design decisions

  • Human-in-the-loop by default. The agent never takes an irreversible action (booking, rescheduling, cancelling) on the first pass — it always proposes, then waits for an explicit confirmation. This bounds the blast radius of an NLU mistake: worst case, the bot proposes the wrong slot and the customer says no.
  • Validate right before acting, not just when parsing. Every guardrail (future date, inside business hours, service belongs to this tenant, slot still free) is re-checked in the calendar backend itself at the moment of booking — not only when the message was first parsed. This re-check is what catches a slot taken by someone else between "propose" and "confirm" at the application level; it is a check-then-insert, not a transaction, so a production deployment would still add a database-level unique constraint or lock for true concurrent-request safety.
  • Handoff instead of guessing. A complaint, an emergency, or a message the extractor can't classify with reasonable confidence goes straight to a handoff queue, and the customer is told a human will follow up. A scheduling bot that fakes empathy or improvises on an urgent message is worse than one that says "let me get someone".
  • Tenant isolation by construction, not by convention. Every read/write in the calendar backend takes a tenant_id and filters by it — see demo/db.py. In this demo that filter lives in the Python/SQL layer, and tests/test_isolation.py proves no business (of all three demo tenants) can ever see or touch another's appointments or handoff tickets. In a production system this demo does not implement, the same guarantee is usually pushed down to the database itself via PostgreSQL Row Level Security (RLS) policies, so isolation holds even if application code forgets a filter somewhere — no implementation details of any real deployment are included here.
  • Business hours are per-tenant data, not a global constant. "Centro de Estética Demo" is open Tuesday-Saturday with a different schedule than the vet/physio tenants (Monday-Friday) — see demo/seed.py and SQLiteCalendarBackend.set_business_hours. This is the same isolation principle applied to configuration, not just to appointment rows: nothing in agent.py assumes a single shared calendar.
  • Pluggable LLM provider. demo/llm/base.py defines the interface; MockLLMProvider is a deterministic, rule-based implementation that needs no key and no network (so this repo runs and its tests pass for anyone, offline); AnthropicLLMProvider is an optional real implementation using Claude's tool use, enabled only if ANTHROPIC_API_KEY is set. Swapping providers requires no change to agent.py.
  • CalendarBackend interface, SQLite behind it. The agent only talks to the CalendarBackend abstract class in demo/db.py. This demo backs it with SQLite so it needs no external service; a production integration would implement the same interface against Google Calendar (or similar) instead.

Project structure

demo/
  models.py            dataclasses: Service, Appointment, HandoffTicket
  db.py                CalendarBackend interface + SQLite implementation
  seed.py               three fictional demo businesses + fictional customers
  llm/
    base.py             LLMProvider interface + Understanding data shape
    mock_provider.py     deterministic, rule-based provider (default)
    anthropic_provider.py optional real provider via Claude tool use
  agent.py              orchestration: guardrails, pending-confirmation state, handoff
  chat.py               interactive console chat (python -m demo.chat)
  scenarios.py          6 scripted example conversations (python -m demo.scenarios)
tests/                  pytest suite (guardrails, handoff, isolation, booking flow)

Run it in 3 commands

pip install -r requirements.txt
python -m pytest
python -m demo.scenarios

Or talk to it interactively:

python -m demo.chat --tenant vet

To try it with real Claude instead of the mock provider, copy .env.example to .env, set ANTHROPIC_API_KEY, pip install anthropic, and export the variable before running (demo/llm/__init__.py picks it up automatically; nothing in the code ever reads a key from a committed file).

Limitations (honest, this is a demo)

  • The mock NLU is rule-based keyword matching in Spanish, tuned to the handful of phrasings used in the tests and scenarios — it is not a general-purpose language understanding system. The optional Claude provider is the one meant to generalize to arbitrary phrasing.
  • The calendar is SQLite, not a real calendar integration; CalendarBackend is the seam where that would plug in.
  • No real WhatsApp transport (Twilio/Meta Cloud API/etc.) is included — demo/chat.py simulates the conversation over a console instead.
  • No authentication, rate limiting, or persistence beyond a single process run (the default database is in-memory).
  • Business hours, services and prices are hardcoded per demo tenant in demo/seed.py, not configurable through any admin UI; there is no concept of holidays/exceptions to the weekly schedule.

En español (resumen breve)

Esto es una demo mínima y autocontenida de un agente de reservas por WhatsApp, pensada para mostrar decisiones de diseño en candidaturas de empleo y en Malt — no es una copia de ningún sistema real en producción. Usa un proveedor de lenguaje "mock" determinista por defecto (sin clave, sin red, resultados reproducibles) y opcionalmente Claude real vía ANTHROPIC_API_KEY. Tres negocios ficticios (veterinaria, fisioterapia y un centro de estética, cada uno con su propio catálogo, precios y horario) comparten la base de datos SQLite pero sus datos están aislados por tenant_id; en producción esto se suele reforzar con Row Level Security en PostgreSQL (sin detalles de ninguna implementación real). El agente nunca reserva sin confirmación explícita, y deriva a una persona cualquier mensaje ambiguo, urgente o de queja.

Ejecución: pip install -r requirements.txt && python -m pytest && python -m demo.scenarios.

License

MIT — see LICENSE.

NoSFeR88/citabot-demo

WhatsApp booking agent demo (portfolio) — mock LLM, SQLite, multi-tenant isolation, guardrails

Python

0

1 commits

updated Sep 28, 2026

See the code

README

CitaBot Demo — WhatsApp Booking Agent

A minimal, self-contained demo of a WhatsApp-style appointment booking agent, built to show the design of a production system like this one — not a copy of any real codebase. It runs with zero setup (no API key, no network, no external services) and every action is backed by a test.

This is a portfolio/demo project by Oliver Mediavilla, built to show how I'd design a booking agent for a small business (a vet clinic, a physio studio, ...) that talks to customers over WhatsApp. It is not a copy of any client's production system — it is a new, minimal implementation of the same ideas.

What it does

  1. A customer sends free text: "¿tenéis hueco el jueves por la tarde para vacunar a mi perro?"
  2. The agent extracts intent and slots (service, day, time of day) via a pluggable "tool calling" boundary.
  3. It checks real availability in a simulated calendar (SQLite) and proposes a slot — it never books anything until the customer explicitly confirms.
  4. Ambiguous messages, complaints, urgencies, or low-confidence extractions are routed to a human handoff queue instead of being guessed at.
  5. Three fictional businesses ("Clínica Veterinaria Ejemplo", "Fisio Demo" and "Centro de Estética Demo") share the same database but their data is fully isolated by tenant_id, and each keeps its own service catalog, prices and weekly schedule.

Flow diagram

flowchart TD
    A[Customer message] --> B{LLM provider<br/>extract intent + slots}
    B -->|complaint / urgent / low confidence| H[Human handoff queue]
    B -->|book| C[Find service by keyword]
    B -->|reschedule / cancel| D[Find existing appointment]
    B -->|confirm / deny reply| P{Pending proposal<br/>for this phone?}

    C -->|not found| H
    C -->|found| E[Validate: future date,<br/>business hours, day open]
    D --> E

    E -->|invalid| R1[Reply: explain why, ask again]
    E -->|valid| F[Query available slots<br/>in SQLite calendar]

    F -->|none free| R2[Reply: no availability,<br/>suggest another day]
    F -->|slots found| G[Propose a slot,<br/>store as pending]

    P -->|no pending| R3[Reply: nothing to confirm]
    P -->|deny| R4[Clear pending, reply: no changes made]
    P -->|confirm| I[Re-validate against calendar<br/>right before acting]

    I -->|guardrail fails<br/>e.g. slot taken meanwhile| R2
    I -->|ok| J[(Book / reschedule / cancel<br/>in SQLite, scoped by tenant_id)]
    J --> K[Reply: confirmation]

Demo businesses

Tenant (--tenant)Business nameHoursServices (fictional prices)
vetClínica Veterinaria EjemploMon-Fri, 09:00-13:00 & 15:00-19:00vacunación (25€), consulta general (35€), peluquería (30€)
fisioFisio DemoMon-Fri, 09:00-13:00 & 15:00-19:00sesión fisioterapia (45€), valoración inicial (20€), masaje deportivo (40€)
esteticaCentro de Estética DemoTue-Sat, 10:00-14:00 & 16:00-20:00limpieza facial (38€), depilación láser -zona- (50€), manicura semipermanente (22€), masaje (42€)

All three share the same SQLite database and are isolated by tenant_id (see tests/test_isolation.py). estetica was added specifically to prove that per-tenant configuration (schedule, catalog, prices) is not a global constant either — see "Design decisions" below.

Design decisions

  • Human-in-the-loop by default. The agent never takes an irreversible action (booking, rescheduling, cancelling) on the first pass — it always proposes, then waits for an explicit confirmation. This bounds the blast radius of an NLU mistake: worst case, the bot proposes the wrong slot and the customer says no.
  • Validate right before acting, not just when parsing. Every guardrail (future date, inside business hours, service belongs to this tenant, slot still free) is re-checked in the calendar backend itself at the moment of booking — not only when the message was first parsed. This re-check is what catches a slot taken by someone else between "propose" and "confirm" at the application level; it is a check-then-insert, not a transaction, so a production deployment would still add a database-level unique constraint or lock for true concurrent-request safety.
  • Handoff instead of guessing. A complaint, an emergency, or a message the extractor can't classify with reasonable confidence goes straight to a handoff queue, and the customer is told a human will follow up. A scheduling bot that fakes empathy or improvises on an urgent message is worse than one that says "let me get someone".
  • Tenant isolation by construction, not by convention. Every read/write in the calendar backend takes a tenant_id and filters by it — see demo/db.py. In this demo that filter lives in the Python/SQL layer, and tests/test_isolation.py proves no business (of all three demo tenants) can ever see or touch another's appointments or handoff tickets. In a production system this demo does not implement, the same guarantee is usually pushed down to the database itself via PostgreSQL Row Level Security (RLS) policies, so isolation holds even if application code forgets a filter somewhere — no implementation details of any real deployment are included here.
  • Business hours are per-tenant data, not a global constant. "Centro de Estética Demo" is open Tuesday-Saturday with a different schedule than the vet/physio tenants (Monday-Friday) — see demo/seed.py and SQLiteCalendarBackend.set_business_hours. This is the same isolation principle applied to configuration, not just to appointment rows: nothing in agent.py assumes a single shared calendar.
  • Pluggable LLM provider. demo/llm/base.py defines the interface; MockLLMProvider is a deterministic, rule-based implementation that needs no key and no network (so this repo runs and its tests pass for anyone, offline); AnthropicLLMProvider is an optional real implementation using Claude's tool use, enabled only if ANTHROPIC_API_KEY is set. Swapping providers requires no change to agent.py.
  • CalendarBackend interface, SQLite behind it. The agent only talks to the CalendarBackend abstract class in demo/db.py. This demo backs it with SQLite so it needs no external service; a production integration would implement the same interface against Google Calendar (or similar) instead.

Project structure

demo/
  models.py            dataclasses: Service, Appointment, HandoffTicket
  db.py                CalendarBackend interface + SQLite implementation
  seed.py               three fictional demo businesses + fictional customers
  llm/
    base.py             LLMProvider interface + Understanding data shape
    mock_provider.py     deterministic, rule-based provider (default)
    anthropic_provider.py optional real provider via Claude tool use
  agent.py              orchestration: guardrails, pending-confirmation state, handoff
  chat.py               interactive console chat (python -m demo.chat)
  scenarios.py          6 scripted example conversations (python -m demo.scenarios)
tests/                  pytest suite (guardrails, handoff, isolation, booking flow)

Run it in 3 commands

pip install -r requirements.txt
python -m pytest
python -m demo.scenarios

Or talk to it interactively:

python -m demo.chat --tenant vet

To try it with real Claude instead of the mock provider, copy .env.example to .env, set ANTHROPIC_API_KEY, pip install anthropic, and export the variable before running (demo/llm/__init__.py picks it up automatically; nothing in the code ever reads a key from a committed file).

Limitations (honest, this is a demo)

  • The mock NLU is rule-based keyword matching in Spanish, tuned to the handful of phrasings used in the tests and scenarios — it is not a general-purpose language understanding system. The optional Claude provider is the one meant to generalize to arbitrary phrasing.
  • The calendar is SQLite, not a real calendar integration; CalendarBackend is the seam where that would plug in.
  • No real WhatsApp transport (Twilio/Meta Cloud API/etc.) is included — demo/chat.py simulates the conversation over a console instead.
  • No authentication, rate limiting, or persistence beyond a single process run (the default database is in-memory).
  • Business hours, services and prices are hardcoded per demo tenant in demo/seed.py, not configurable through any admin UI; there is no concept of holidays/exceptions to the weekly schedule.

En español (resumen breve)

Esto es una demo mínima y autocontenida de un agente de reservas por WhatsApp, pensada para mostrar decisiones de diseño en candidaturas de empleo y en Malt — no es una copia de ningún sistema real en producción. Usa un proveedor de lenguaje "mock" determinista por defecto (sin clave, sin red, resultados reproducibles) y opcionalmente Claude real vía ANTHROPIC_API_KEY. Tres negocios ficticios (veterinaria, fisioterapia y un centro de estética, cada uno con su propio catálogo, precios y horario) comparten la base de datos SQLite pero sus datos están aislados por tenant_id; en producción esto se suele reforzar con Row Level Security en PostgreSQL (sin detalles de ninguna implementación real). El agente nunca reserva sin confirmación explícita, y deriva a una persona cualquier mensaje ambiguo, urgente o de queja.

Ejecución: pip install -r requirements.txt && python -m pytest && python -m demo.scenarios.

License

MIT — see LICENSE.

Languages

Python

100.0%