How Twiga Works
One FastAPI service sits between WhatsApp and a language model, with a Postgres database holding both the conversation and the textbook.
Twiga is a single deployable. A message arrives from Meta, the service works out who sent it and what state they are in, asks a model for an answer, lets that model call tools if it needs to, and sends the reply back. Postgres holds users, conversation history and the textbook chunks the model retrieves from.
| Route | Purpose |
|---|---|
GET /webhooks | Meta's verification handshake. Echoes the challenge. |
POST /webhooks | Every incoming message. Guarded by an HMAC signature check. |
POST /devhooks | The same handler with no signature check, for the mock interface. Disabled unless MOCK_WHATSAPP is on. |
POST /flows | Encrypted WhatsApp Flows data exchange. |
GET /health | Liveness only. It returns OK without touching the database. |
GET /metrics | Prometheus metrics. |
Following one text message from arrival to reply. File paths are relative to the repository root.
app/security.py checks the X-Hub-Signature-256 header against an HMAC of the raw body keyed with your app secret. A mismatch is a 403.
app/services/request_service.py then classifies the payload. Status updates, flow events and malformed bodies each get their own handler. Anything with a timestamp more than ten seconds old is dropped, which matters when you are debugging with breakpoints.
The sender's WhatsApp ID is looked up in the users table. An unknown number goes to registration. A known one has its message persisted, gets a rate limit check against Redis, and is then routed on user.state: blocked, in review, approved, inactive, onboarding or active. Only active users reach the model.
app/clients/client_base.py assembles the request: the system prompt from app/assets/prompts/, the teacher's name and classes, then the last ten messages of history.
History is filtered. Previous tool calls and tool results are stripped out, because some providers require a tool call to be immediately followed by its response. Only the current turn's tool results reach the model.
The first call includes the tool schemas. If the model asks for a tool, the user gets a short holding message, the tools run, and their results are appended for a second call that produces the final answer. The provider is chosen at runtime through LangChain, so Together, OpenAI, Google, Ollama and Modal all work behind the same interface. Failed calls retry with exponential backoff.
Set AGENTIC_MODE_ENABLED to swap the single round trip for a loop that keeps thinking and acting until it has an answer, capped at eight iterations.
Before anything is sent, the reply passes through several checks in app/services/messaging_service.py:
Whatever survives is sent to the Cloud API. Replies of three options or fewer become interactive buttons, and more than that becomes a list.
| Directory | What lives there |
|---|---|
app/clients/ | Outbound clients. The WhatsApp client, and the two LLM clients (single round trip and agent loop). |
app/services/ | Routing, the user state machine, reply post-processing, flows, exams, citations and rate limiting. |
app/tools/ | The tool registry and each tool implementation. See Adding a Tool. |
app/database/ | SQLModel tables, enums, queries and the async engine. Vector search lives here too. |
app/models/ | Shapes for outbound WhatsApp JSON. Not database models, despite the name. |
app/utils/ | Payload builders, the provider factory, the embedder and the prompt manager. |
app/assets/ | Prompts, model defaults and every user-facing string, as YAML. Loaded at import. |
app/latex/ | LaTeX to PDF to image rendering. |
app/monitoring/ | Prometheus metric definitions. |
scripts/ | Seeding, textbook ingestion, re-embedding and the scheduled jobs. |
docs/en/ARCHITECTURE.md points at app/llm_service.py and app/database/model.py. Neither exists. The real paths are app/clients/llm_client.py and app/database/models.py.
Some files in app/services are dead copies
app/services/ still holds older forks of client_base.py, agent_client.py, llm_service.py, whatsapp_service.py and latex_image_service.py. Nothing reachable from app/main.py imports them, but the test suite does, and they have drifted from the live versions in app/clients/ and app/latex/. If an edit seems to have no effect at runtime, check which copy you changed.Everything is read through app/config.py. Import settings from there rather than reaching for dotenv or os.environ, which is a rule the project states explicitly.
Seven variables are asserted at import time, so a missing one stops the process from starting rather than failing on the first request. Non-secret defaults come from app/assets/config/base.yml, not from the code.
ENVIRONMENT changes more than logging. Outside production, an unknown number is silently turned into a ready-made Geography Form 2 teacher. Redis and rate limiting only start in production and staging.scripts/. The flow service depends on a module there, which is why PYTHONPATH is set in the Makefile targets.