← Case studiesMulti-tenant agentic AI chatbot SaaS
Role
Backend & AI systems engineer
Timeline
2024 — Present
01Overview & problem
Businesses wanted AI chatbots on the channels their customers already live in — Messenger, WhatsApp, Instagram, email — but each channel came with its own delivery semantics, retry behaviour and failure modes. Off-the-shelf chatbot tools answered from a static FAQ and fell apart the moment a conversation needed real product knowledge or a business action.
Argon was designed as the alternative: every tenant gets an isolated workspace with its own chatbots, documents and automations. Ingested documents become searchable embeddings; an agent layer grounds every answer in retrieved facts and can take real actions — qualify a lead, book an appointment, or hand off to a human — instead of hallucinating confidently.
Who it affected
Tenant end-customers (chatting on social channels), tenant operators (watching conversations, configuring bots), and the platform team (running one multi-tenant deployment instead of N installs).
Why it mattered
Trust is the product. A chatbot that leaks one tenant's data or invents one wrong price destroys the entire value proposition — so tenant isolation and grounded answers were architectural constraints, not features.
10Media gallery
02Constraints & goals
Tenancy
One deployment, many workspaces
Strict data + retrieval isolation per tenant
Delivery
At-least-once webhooks
Channels redeliver; handlers must be idempotent
Latency budget
Agent turn ≈ seconds
Async pipeline keeps channels off the hot path
Cost
Token-aware context
Retrieval + budgeted prompts, not prompt stuffing
Channels
4 external APIs
Messenger, WhatsApp, Instagram, Amazon SES
Team
Small team
Operational simplicity weighted heavily
03System architecture
Channel webhooks terminate on the Django API but all side effects happen in Celery — the request path only verifies, dedupes and enqueues.
Redis wears three hats deliberately (broker, channel layer, cache) to keep the ops surface small for a small team.
The agent runtime runs inside workers, so LLM latency never blocks a webhook response and agent capacity scales independently of the API.
Diagram is illustrative — component granularity reflects the design, not an exact deployment map.
04Data model & database design
Why this schema
Every tenant-owned table carries workspace_id, including chunks. That single decision makes isolation a WHERE clause away, lets every hot index lead with workspace_id, and keeps vector search, relational joins and business actions in one transactional system instead of stitching a vector DB and a relational DB into one consistency story.
Composite indexes lead with workspace_id (e.g. chunks(workspace_id, document_id), conversations(workspace_id, chatbot_id)).
Isolation today is enforced at the ORM manager layer with a Postgres RLS policy as the planned hardening step — the schema is already shaped for it.
chunks is designed partition-ready (by workspace hash) if per-tenant growth becomes skewed; pgvector ANN indexes (HNSW/IVFFlat) are per-partition friendly.
tsv alongside embedding enables hybrid retrieval experiments without a migration.
09Tech stack
05Engineering decisions
Decision
Shared tables with mandatory workspace_id scoping, composite indexes leading with workspace_id, and an RLS-ready schema.
Alternatives considered
Schema per tenant
Clean isolation but migrations × tenants becomes an operational tax; connection pooling gets awkward.
Database per tenant
Strongest isolation; far too much operational overhead at this stage and price point.
Why this one
At current scale, row-level scoping gives 95% of the isolation value for 5% of the operational cost, and the schema shape means flipping on Postgres RLS later is a hardening step, not a rewrite.
Decision
Run the Google ADK agent layer inside Celery workers, invoked as async tasks — never in the web request path.
Alternatives considered
In-process Django views
Simplest to write; couples LLM latency to HTTP workers and one slow generation stalls requests.
Separate FastAPI microservice
Great isolation for the agent hot path (this is the direction taken at work), but more moving parts than needed initially.
Why this one
Webhook responses stay fast, agent capacity scales independently by adding workers, and retries/DLQ semantics for agent turns come free with Celery.
Decision
PostgreSQL + pgvector with workspace-scoped ANN indexes.
Alternatives considered
Pinecone
Excellent managed ANN; used in production at Echologyx for a different system. Adds a second system to keep consistent with relational truth.
Vertex AI Vector Search
Strong at GCP scale; overkill for tenant document collections and another permission boundary to mirror.
Why this one
Colocating vectors with relational data means retrieval filters (workspace, chatbot, status) are plain SQL predicates, and business actions + retrieval share one transaction boundary. One backup, one disaster story.
Decision
At-least-once processing: idempotency keys on inbound webhooks, Celery retry with exponential backoff, and a dead-letter queue.
Alternatives considered
Fire-and-forget handlers
Loses messages on any crash; channels redeliver on timeout so duplicates, not losses, are the reality to design for.
External queue (SQS)
Solid option; redundant when Redis + Celery already provide retry and DLQ semantics.
Why this one
Every channel API redelivers when you don't ACK fast enough — so the system assumes duplicates and makes processing idempotent instead of pretending delivery is exactly-once.
07Challenges & solutions
Cross-tenant leakage in retrieval
Early query helpers fetched by primary key without tenant scoping — a chatbot could theoretically retrieve another workspace's chunks. Classic multi-tenant bug class.
A workspace-scoped default manager plus composite indexes leading with workspace_id, with an RLS policy as the defense-in-depth next step.
class WorkspaceQuerySet(models.QuerySet):
def for_workspace(self, ws):
return self.filter(workspace_id=ws.id)
class Chunk(models.Model):
workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE)
document = models.ForeignKey(Document, on_delete=models.CASCADE)
objects = WorkspaceQuerySet.as_manager()
# ✅ isolation is a query predicate, enforced by the default manager
def search(ws, query_vec, chatbot_id, k=5):
return (Chunk.objects
.for_workspace(ws)
.filter(document__chatbot_id=chatbot_id)
.order_by("embedding_cosine", ("embedding", query_vec))
.only("id", "content")[:k])Tenant scoping became the default path; forgetting it requires deliberately bypassing the manager.
Context bloat burning tokens
Stuffing whole documents and full history into prompts made responses slow and expensive, and accuracy dropped as noise grew.
Top-k retrieval with reranking, rolling summaries for history, and an explicit token budget the router must respect.
def build_prompt(ws, conversation, query, budget=TOKEN_BUDGET):
# ✅ top-k retrieval, reranked, budgeted
hits = rerank(vector_search(ws, embed(query), k=24), keep=6)
history = summarize(conversation.recent(window=10))
ctx = fit([h.content for h in hits], budget - count(history))
return Prompt(history=history, context=ctx, query=query)Prompts carry only what the turn needs; the router treats the token budget like a resource, not an afterthought. [METRIC: measured token reduction — add real number]
Duplicate deliveries from channel APIs
Messenger and WhatsApp redeliver webhooks aggressively. Naive handlers created duplicate messages and double-replied to customers — visibly broken.
Idempotency keys with INSERT ... ON CONFLICT DO NOTHING, plus Celery retry/backoff for outbound sends.
-- inbound: one row per external event id
INSERT INTO webhook_events (id, channel, event_id, payload)
VALUES (:id, 'messenger', :event_id, :payload)
ON CONFLICT (channel, event_id) DO NOTHING;
-- rows affected = 0 → replay detected, ACK and stop
-- outbound: delivery tasks keyed by (message_id, attempt_scope)
-- retried with exponential backoff, parked in a DLQ after N triesReplays become no-ops; customers get exactly one reply per message. [METRIC: duplicate rate — add real number]
08Results & impact
external channels integrated
agent-driven business workflows
p95 agent turn latency
token usage after context redesign
Prompt tokens per turn — before / after context redesign
ILLUSTRATIVE// Shape reflects the design change (retrieval + budgeted context). Replace with measured production numbers.
// Numbers marked [METRIC] are placeholders — swap in measured values before publishing.
Next case study
AI-powered travel planning platform