Martita
WhatsApp AI receptionist for Mexican SMEs. Proactive by design — a cron heartbeat that acts before anyone messages. 21 tools, 2,640 tests, built solo.
Martita is a WhatsApp receptionist for Mexican small businesses that run on appointments: dental clinics, salons, medical practices, physiotherapists. Their customers message a normal WhatsApp number and Martita answers in Mexican Spanish, quotes services and prices, checks real availability, books, reschedules and cancels, and writes it all to the owner’s Google Calendar.
The owner uses the same number to run the business. “¿Qué tengo mañana?”, “bloquéame el viernes de 2 a 4”, “agenda a María el lunes a las 3.” Same phone number, different person, different permissions.
I started it in July 2025, made enough of a mess of it that I threw the codebase away, and started again from scratch in December. This is the second one: seven months, 587 commits, 29,000 lines of application code and about 56,000 lines of tests.
Where it honestly stands
Two tenants in production, one of them a friend’s real business running as a demo, the other mine for development. Nobody is paying yet. Billing is a mock page that sets a timestamp; Stripe is scoped but not wired. I’m using the friends-and-family period to find out what actually breaks with real customers on the other end before charging anyone.
The bet: it acts first
Inbound chatbots are a commodity. Anyone can wire Claude to a webhook and answer questions. What’s harder, and what I spent most of the effort on, is an assistant that does work when nobody has messaged it.
A cron-driven heartbeat runs every thirty minutes and checks the state of the world: appointment reminders 24 hours ahead, a morning briefing composed for the owner between 7 and 8, post-visit follow-ups late morning, a daily platform health report.
Two constraints shape that whole subsystem. WhatsApp only permits free-form messages within 24 hours of the customer’s last inbound one, so every proactive send checks the window with a safety margin and falls back to an approved template outside it. And every send is audit-logged behind a uniqueness constraint, with reminders claiming their slot before sending, because a heartbeat that fires twice must not message a patient twice.
One Claude call, 21 tools
There is no if 'agendar' in message anywhere in the codebase, and that’s an enforced rule rather than an accident. Understanding Mexican Spanish — “el jueves que viene tempranito”, implied services, three thoughts in one message — is the model’s job. A single Claude Haiku call with 21 tool definitions decides what to do, in a loop capped at five iterations.
The system prompt isn’t static. Up to eleven context blocks are assembled per message, each its own scoped query: the business and its services, who this customer is and what’s missing before they can book, their upcoming appointments, stored preferences, owner notes, cross-conversation memory, and a table of contents of the business’s knowledge base so the model knows what it can look up.
Two of those blocks are computed rather than fetched. A behavioural profile derived from up to 50 past appointments produces a cancellation risk tier, the hour this person usually books, and how long they typically go between visits — suppressed below three appointments, because three points aren’t a pattern. From that comes a single actionable sentence: a repeat canceller gets confirm availability before committing; someone 90 days absent gets welcome them back, suggest their usual.
The detail I’m fondest of costs 40 tokens. A precomputed 14-day calendar is injected into every prompt with the instruction to consult it and never compute from memory. LLMs are bad at “which weekday is the 8th?”, and that one block eliminated an entire class of wrong-day bookings.
The ghost action
The failure that worried me most isn’t downtime. It’s Martita saying “¡Listo! Tu cita quedó agendada para el viernes a las 3” without having called book_appointment. The patient shows up. Nothing is on the calendar. That happens once and the business never trusts the product again, and it’s silent — no error, no alert, nothing to page on.
So every outgoing message is checked before it’s sent. Seven action classes (booking, cancellation, reschedule, blocking time, removing a block, promising to notify someone, changing consent) each have tuned Spanish patterns that look for confirmation language, cross-referenced against what the model actually called. Suppression requires that the tool ran and returned its success prefix. Naming a tool doesn’t count. On a hit, the reply is replaced with a recovery message and the trace gets tagged.
The subtleties are the work, and each one is a bug that got past an earlier version of the guard:
- “No tienes ninguna cita agendada” contains the pattern but states an absence, so matches are checked backwards for negation — within the same clause only.
- Clause boundaries have to include pero, aunque and sin embargo, because Spanish will join an absence to a claim with no punctuation at all: “aún no recibo el pago pero tu cita está confirmada.” Without those, the leading no disarms the guard on a real hallucination.
- Two negation tokens are deliberately narrowed, because a token that fires wrongly disarms the guard and lets a hallucination through, which is far worse than a false positive.
- Negation-suppressed matches are logged anyway, so over-suppression stays measurable instead of invisible.
Not double-booking anyone
Four layers, because each one alone is insufficient: availability is checked before any slot is offered; a Postgres advisory lock per business wraps the booking path to catch the select-then-insert race; availability is re-checked inside that lock to catch overlapping rather than identical times; and a unique constraint on the slot key catches the identical start minute, with the integrity error surfacing as a polite Spanish retry.
Availability itself is the intersection of the week’s business hours, existing appointments plus a buffer, owner time blocks, live Google Calendar events, and travel time between physical locations — that last one because the pilot business works out of two towns and keeps a calendar per location. Martita won’t offer 10:00 in one town when there’s a 09:30 in the other.
Multi-tenancy, defended four ways
One deployment serves every business. Isolation is enforced at four independent layers: every query is explicitly scoped and the resolver raises rather than defaulting to tenant 1; a SQLAlchemy hook inspects every statement before execution and raises in production if a tenant table isn’t scoped; an AST-based CI lint blocks the merge; and per-business HMAC API keys scope the admin surface.
The residual risk is written down. Primary-key lookups bypass the runtime net by design, so the caller must verify tenancy after fetching. That’s a convention enforced by review, not by the compiler.
How it’s tested
2,640 tests against 29,000 lines of application code, roughly two lines of test per line of app. A separate CI lane runs against real Postgres for everything SQLite can’t model — advisory locks, real constraint behaviour — and is rigged to fail if it collects zero tests, because a green lane should have to prove it ran.
Beyond linting, four custom gates block merges: tenant-scoping analysis, a PII gate that runs in CI and as a pre-commit hook so customer data can’t enter git, an import-cycle guard, and a drift test asserting the agent can’t describe a capability that doesn’t exist.
And 16 golden scenarios run against the live model before any prompt or tool change: booking, availability discipline, cancellation permissions by persona, proxy booking, escalation, and two explicit anti-ghost cases. Unit tests structurally cannot catch a prompt regression. That harness can, and it found the bug where Claude speaks before a tool call and then ends the turn with empty content.
What I chose not to build
No Redis, no Celery, no Kubernetes, no vector database, no microservices, no frontend build step. The whole product is one Flask process and a Postgres database, with HTMX doing the work a React app would otherwise be doing. It’s cheap to run and it fits in one person’s head, which matters when that person is the entire on-call rotation.
That has a cost, and the honest version is this: background work runs on an in-process thread pool rather than a durable queue, so a restart mid-task loses that reply. Rate limits and metrics are per-process and in-memory, which means with two workers the effective limits are roughly double what’s configured. Both are fine at pilot scale and both need replacing before real volume. They’re the next thing, along with Stripe.
Stack
Python 3.12, Flask 3.1, SQLAlchemy 2.0 with typed models, Alembic, Postgres on Supabase. Claude Haiku 4.5 with prompt caching and a circuit breaker. Meta’s WhatsApp Cloud API, with Kapso as a proxy for the multi-tenant provisioning Meta makes painful. Jinja2, HTMX and Tailwind for every surface. Gunicorn on Railway, Langfuse for agent traces, Sentry for errors.
Martita is also where I test production agent patterns before they reach enterprise work: evaluator loops, structured outputs, cost and latency observability, and what it actually takes to keep an agent honest.