Archimede

WhatsApp assistant for a tutoring association

WhatsApp bot for Eureka, a non-profit association that organises school tutoring. It recognises the sender's role from their number — student, tutor, family, office — and answers with Gemini from a knowledge base curated by the office, or hands the conversation to an operator as a ticket. A web console lets the office read chats, assign roles and update what the bot knows. Two independent services (FastAPI and Next.js) on a shared PostgreSQL, exposed only through a Cloudflare Tunnel.

Year
2026 — present
Role
Full-stack development
Status
In production
Client
Eureka
PythonFastAPIGeminiPostgreSQLTypeScriptNext.jsDrizzle ORMAuth.jsDockerCloudflare TunnelWhatsApp Cloud API

Overview

A contact writes on WhatsApp; the system recognises from the number whether they are an enrolled student, a tutor, a family or the office, and answers autonomously with a language model or hands the conversation to an operator. The office works from a dedicated web console: it reads conversations, assigns roles, blocks abusers and updates the knowledge base the bot uses to answer. Most users are minors, so privacy, retention and access control are project requirements, not finishing touches.

Problem

A small organisation gets a lot of repetitive questions on WhatsApp — schedules, how to enrol, how to book a lesson — mixed with requests that need a person. It needed to:

  • automatically filter questions with a known answer, without the bot "promising" the wrong thing;
  • never lose a message that needs a human, with a ticket queue for the office;
  • give different answers to different roles: a student and an admin don't see the same information;
  • let the office correct what the bot knows without touching the code;
  • stay manageable by a few people, with minimal infrastructure cost.

Architecture

  • Gateway (Python / FastAPI): receives Meta's signed webhooks, deduplicates, downloads attachments, applies rate limiting, recognises the sender's role and chooses between an automatic answer (Gemini 2.5 Flash with function calling) and a handoff to the office.
  • Console (Next.js / TypeScript / Auth.js / Drizzle): the office panel. Magic-link email login, JWT sessions, role re-read from the DB on every action, per-request nonce CSP.
  • The two services never talk directly: they share a PostgreSQL with two databases, each writes only its own and reads the other's. The "one writer per table" rule is enforced in application code.
  • A single public entry point via Cloudflare Tunnel (TLS on Cloudflare's side), with the infrastructure Docker stack kept separate from the apps: a rebuild does not restart the database.
WhatsApp Cloud API
      │  webhook (HMAC signature)
      ▼
Cloudflare Tunnel ─► nginx ─► Gateway (FastAPI) ──► Gemini 2.5 Flash
                                   │
                             PostgreSQL 16  ◄──►  Console (Next.js) ── office

Implementation

The ordered decision pipeline is the most delicate logic in the system and is frozen by tests: signature + dedup → console data available? → rate limit → media download → sender blocked? → tutor intercepts → office hours → answer with Gemini. The webhook returns 200 immediately and processing continues in the background.

The bot's content lives in two separate lifecycles: the prompts (personality and rules) versioned in the repo; the knowledge (facts about the association) in Postgres, editable from the console, segmented by role from the document's folder. The .md files in the repo are only the initial seed.

Periodic tasks — retention cleanup, ticket escalation, member-book sync — run without an external scheduler, off the event loop, and /health returns 503 if they fall behind.

Design decision

Without the role data from the console the bot stays silent, rather than treating everyone as an unknown. Not answering is better than answering with incomplete information.

Challenges

  • The order of decisions is not obvious: moving a check in the pipeline changes behaviour in subtle ways; the only defence is the test suite that pins it down.
  • "Looks working while it isn't": a pre-production audit led to counter-intuitive choices, like degrading apparent availability in order not to give wrong answers.
  • Two sources of truth for content: the knowledge used to live on read-only mounted files and console edits never reached the bot; fixed by moving it to Postgres.
  • Minor users: 30-day retention enforced in code, with cascading deletion on the full-text search index too.

Results

  • In production, with a separate test environment on a second clone of the VPS and a deploy script that refuses mismatched branches or a dirty working tree.
  • Small, tested codebase: pipeline, roles, office hours and knowledge isolation have dedicated tests; CI on GitHub Actions for type check, lint, build and tests.
  • Low-cost infrastructure: a single VPS, shared PostgreSQL, no service exposed directly, entry via Cloudflare Tunnel.

Lessons learned

  • On a channel where the bot "promises" schedules and enrolments, fixed replies must be centralised and kept out of the model: they are the only source of truth toward the user.
  • Real resilience is deciding what to do when a dependency is down: staying safely silent beats answering with incomplete data.
  • Separating two services through the database, with one writer per table, keeps deploys independent without introducing a message queue.
  • Writing ADRs pays off: the counter-intuitive choices stay understandable months later.