WhatsApp booking agent demo (portfolio) — mock LLM, SQLite, multi-tenant isolation, guardrails
Python
0
1 commits
updated Sep 28, 2026
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.
tenant_id, and each keeps its own service catalog,
prices and weekly schedule.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]
Tenant (--tenant) | Business name | Hours | Services (fictional prices) |
|---|---|---|---|
vet | Clínica Veterinaria Ejemplo | Mon-Fri, 09:00-13:00 & 15:00-19:00 | vacunación (25€), consulta general (35€), peluquería (30€) |
fisio | Fisio Demo | Mon-Fri, 09:00-13:00 & 15:00-19:00 | sesión fisioterapia (45€), valoración inicial (20€), masaje deportivo (40€) |
estetica | Centro de Estética Demo | Tue-Sat, 10:00-14:00 & 16:00-20:00 | limpieza 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.
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.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.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.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)
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).
CalendarBackend
is the seam where that would plug in.demo/chat.py simulates the conversation over a console instead.demo/seed.py, not configurable through any admin UI; there is no concept
of holidays/exceptions to the weekly schedule.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.
MIT — see LICENSE.
Python
100.0%
WhatsApp booking agent demo (portfolio) — mock LLM, SQLite, multi-tenant isolation, guardrails
Python
0
1 commits
updated Sep 28, 2026
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.
tenant_id, and each keeps its own service catalog,
prices and weekly schedule.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]
Tenant (--tenant) | Business name | Hours | Services (fictional prices) |
|---|---|---|---|
vet | Clínica Veterinaria Ejemplo | Mon-Fri, 09:00-13:00 & 15:00-19:00 | vacunación (25€), consulta general (35€), peluquería (30€) |
fisio | Fisio Demo | Mon-Fri, 09:00-13:00 & 15:00-19:00 | sesión fisioterapia (45€), valoración inicial (20€), masaje deportivo (40€) |
estetica | Centro de Estética Demo | Tue-Sat, 10:00-14:00 & 16:00-20:00 | limpieza 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.
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.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.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.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)
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).
CalendarBackend
is the seam where that would plug in.demo/chat.py simulates the conversation over a console instead.demo/seed.py, not configurable through any admin UI; there is no concept
of holidays/exceptions to the weekly schedule.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.
MIT — see LICENSE.
Python
100.0%