Skip to content

← Case studiesMulti-tenant agentic AI chatbot SaaS

A multi-tenant agentic chatbot SaaS with workspace isolation and configurable chatbots: RAG over PostgreSQL + pgvector, business workflows like lead scoring, appointment booking and human handoff, wired into external communication channels.

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

100%
REST · WSenqueueinvokeprompttop-k · toolsCRUDwebhooks · billingdeliverClientswidget · dashboardDjango AppREST · WS · billingAsync CoreRedis · CeleryADK Agentsrouter · toolsData LayerPG · pgvector · R2Geminigrounded LLMExternalStripe · channels
The system at a glance: clients, app core, async runtime, agent layer, data and externals.
  • 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

100%
1 : N1 : N1 : N1 : N1 : N1 : N1 : N1 : N0..1 : Nworkspaces[PK] iduuidnametext[IDX] slugtextplanenumowner_emailcitextcreated_attimestamptzsubscriptions[PK] iduuid[FK] workspace_iduuid[IDX] stripe_customer_idtextstripe_sub_idtext[IDX] statusenumcurrent_period_endtimestamptzchatbots[PK] iduuid[FK] workspace_iduuidnametextpersonajsonbmodel_configjsonb[IDX] is_activebooleandocuments[PK] iduuid[FK] workspace_iduuid[FK] chatbot_iduuidr2_keytextmimetext[IDX] statusenumchunks[PK] iduuid[FK] document_iduuid[FK] workspace_iduuidchunk_indexintcontenttext[VEC] embeddingvector(1536)[IDX] tsvtsvectorconversations[PK] iduuid[FK] chatbot_iduuid[FK] workspace_iduuid[IDX] channelenumexternal_user_idtexthanded_offbooleanmessages[PK] iduuid[FK] conversation_iduuid[IDX] roleenumcontenttexttoken_countintadk_tracejsonbleads[PK] iduuid[FK] conversation_iduuid[FK] workspace_iduuid[IDX] emailcitextscoreint[IDX] statusenumappointments[PK] iduuid[FK] conversation_iduuid[FK] lead_iduuid[IDX] schedule_attimestamptztimezonetextstatusenum
Core relational model. Every tenant-owned table carries workspace_id for isolation.

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

Layers as swimlanes, tools as nodes. Hover or tap a tool to pin it — its connections trace across layers, with the why in the side panel.
stack.yaml7 layers · 13 tools
L1API
L2Async
L3AI
L4Data
L5Billing
L6Infra
L7Observability

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

01

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.

models.py — after
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.

02

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.

context.py — after
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]

03

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.

idempotency — after
-- 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 tries

Replays become no-ops; customers get exactly one reply per message. [METRIC: duplicate rate — add real number]

08Results & impact

0

external channels integrated

0

agent-driven business workflows

[METRIC: p95 latency]

p95 agent turn latency

[METRIC: token reduction %]

token usage after context redesign

Prompt tokens per turn — before / after context redesign

ILLUSTRATIVE
Before — full stuffing100 tokens
After — top-k + summary32 tokens

// 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