AdminOS
A back office that lives inside WhatsApp
Multi-tenant business software for South African SMEs, built on the observation that the interface they already use every day is not a dashboard.
- 214 commits
- 2026-03-18 → 2026-07-22
- TypeScript 87.8%
South African small businesses do not log in. They answer WhatsApp. Any back office that requires a browser session, a password and a habit is competing against a chat thread that is already open, and it loses. The dashboard still has to exist — somebody reconciles, somebody reads the numbers — but it is the back office of the back office.
That inverts the usual build. The webhook is the product surface. The web app is where the results are kept.
4 decisions
What the constraint forced.
Each of these is a choice with a reason attached. The reason is the part worth reading — a stack list tells you what was used, and nothing at all about the judgment that put it there.
- 01
Tenant isolation belongs in the database, not in the application.
Every table carries a required tenant id and every policy checks it against the claim on the JWT. The audit log is append-only — no update or delete permission is granted to anyone. The point is the failure mode: with isolation in application code, one missing `where` clause in one handler leaks another business's data. With row-level security, that same bug returns an empty set. Multi-tenancy is the one place where being paranoid at the lowest layer is cheaper than being careful at every higher one.
- 02
Give every step of the workflow its own timeout.
An inbound WhatsApp message walks nine steps — load tenant context, classify intent and sentiment and language, check the FAQ cache, check plan limits, generate, send, log. Each has its own budget, from two seconds for a Redis lookup to twenty for the model call. A single timeout over the whole chain tells you the chain was slow. Per-step budgets tell you which step, which is the difference between an alert and a diagnosis.
whatsapp.inbound loadTenantContext 5s refresh prompt cache if > 24h old classifyIntent 8s intent + sentiment + language, in parallel checkFAQCache 2s Redis — answer instantly if cached checkPlanLimits 3s Redis counter — block AI if over quota generateResponse 20s Claude, cached system prompt - 03
Answer from cache before you answer from the model.
Most inbound messages to a small business are the same six questions. A Redis lookup in front of the model turns those into an instant reply at no marginal cost, and the model handles what is actually novel. The ordering matters more than the caching: plan limits are checked before generation, so a tenant over quota is told so rather than served an expensive answer that then has to be reconciled.
- 04
Cost reduction is a feature with a number on it.
Prompt caching against a large stable tenant context — the business's services, tone, hours, policies — takes the per-message cost down by roughly the same order as the cache hit rate, and that is what makes a low monthly price arithmetically possible rather than aspirational. Pricing a product you have not costed is how SaaS businesses discover their unit economics in month four.
The part that survives the build.
The distribution channel decides the architecture, not the other way round.
Once WhatsApp is the interface, everything downstream changes: the response budget is conversational, not page-load; state is a thread, not a session; identity is a phone number, not an account. A team that builds the dashboard first and adds a WhatsApp integration later ends up with a chat interface shaped like a form. The order of construction is the decision.
Write the tenant boundary before the second tenant exists.
Retrofitting row-level security onto a working single-tenant schema means auditing every query ever written, and being wrong once is a breach rather than a bug. The cheap moment is before there is data.
A queue is not an optimisation, it is what makes a webhook honest.
A webhook has to acknowledge fast. Anything slower than the caller's patience — a model call, an outbound send, a write to three tables — has to happen after the acknowledgement or the platform starts retrying, and retries against non-idempotent work is how one message becomes four.
Built with
- Next.js 14 App Router
- TypeScript
- Supabase (RLS + Realtime)
- Claude API
- Upstash Redis
- Inngest
- Meta WhatsApp Cloud API
- PayFast
Measured
- commits
- 214
- last commit
- 2026-07-22
- journal sections
- 58
- nights logged
- 5
Written from AdminOS BUILD_JOURNEY.md — dossier + 18 dated sessions. The dossier is composed from the journal rather than quoted out of it — the raw sections stay behind the review gate described in docs/THE_FORGE.md §4, and every figure above is read from the GitHub API at build time.
