Contents

Automated Customer Support Agent

This is my walkthrough of a 60-minute design for a multi-tenant support agent platform, written the way I'd present it: requirements first, then the estimates that actually shape the architecture, then the design, then failure modes and tradeoffs. It's one reasonable answer, not the canonical one.

Requirements I'm prioritizing

Functional, in order of what I'd build first:

  1. Customer chats over web; the agent answers from the business's knowledge base and the customer's account data.
  2. The agent asks follow-up questions when it lacks information.
  3. The agent performs a small set of actions (check order, cancel order) against each business's existing systems.
  4. Escalation to a human, and human takeover at any moment — including mid-agent-turn.
  5. Businesses manage their KB, tools, and policies; updates go live in ≤5 minutes.

Non-functional, the ones that drive decisions:

Assumptions I'm adding: customers are authenticated by the business's own site (we receive a signed identity token, we don't run login); order/customer systems are reachable via per-business connectors (REST APIs the business exposes or pre-built integrations like Shopify); the action set is small and schema-defined per business, not open-ended; text only.

Estimates that shape the design

The takeaway from the numbers: this is a moderate-scale distributed system attached to a very large LLM workload. I'll spend my complexity budget on the agent loop, retrieval freshness, and tenant/action safety, and keep the messaging plumbing boring.

Architecture

flowchart TB
    subgraph Clients
        W[Customer web chat widget]
        H[Human agent console]
        B[Business admin / KB sources]
    end

    subgraph Edge
        GW[API gateway<br/>auth, rate limits]
        WS[WebSocket gateway<br/>~100K conns]
    end

    subgraph Core
        CS[Conversation service<br/>state machine, message append]
        Q[(Message queue<br/>partitioned by conversation)]
        ORCH[Agent orchestrator<br/>tool loop, streaming]
        MG[Model gateway<br/>multi-provider, budgets, failover]
        ACT[Actions service<br/>tool registry, authz, idempotency]
        RET[Retrieval service<br/>hybrid search, tenant filter]
        ESC[Escalation service<br/>routing, agent queues]
    end

    subgraph Data
        MDB[(Message store<br/>partition: conversation_id)]
        RDS[(Config DB<br/>tenants, tools, policies)]
        RED[(Redis<br/>active conv state, presence)]
        VIX[(Vector + keyword index<br/>50M chunks, sharded)]
        S3[(Object storage<br/>archive, traces)]
        AUD[(Audit log)]
    end

    subgraph Ingestion
        ING[KB ingestion pipeline<br/>chunk, embed, upsert ≤5 min]
    end

    subgraph External
        LLM1[Provider A]
        LLM2[Provider B]
        BIZ[Business order/customer APIs]
    end

    W --> GW --> WS
    H --> GW
    WS <--> CS
    CS --> MDB
    CS --> RED
    CS --> Q --> ORCH
    ORCH --> MG
    MG --> LLM1
    MG --> LLM2
    ORCH --> RET --> VIX
    ORCH --> ACT --> BIZ
    ACT --> AUD
    ORCH -->|stream tokens| CS
    ORCH --> ESC
    ESC --> H
    B --> ING --> VIX
    CS --> S3
    RDS --- ORCH
    RDS --- ACT

Data flow: one customer message

  1. Customer sends a message over WebSocket. The gateway authenticates the session (a JWT the business's site minted, carrying tenant_id and customer_id) and forwards to the conversation service.
  2. The conversation service appends the message to the message store with a per-conversation sequence number, acks the client, and publishes a turn_requested event to a queue partitioned by conversation_id — so turns for one conversation are processed in order, and one slow conversation can't block others.
  3. An orchestrator worker claims the event and checks the conversation's control state in Redis. If a human has taken over, it drops the turn — the human replies instead.
  4. The orchestrator builds context: business system prompt and policies from config, recent messages (older history compressed into a running summary), customer profile fetched through the actions layer, and KB chunks from retrieval — hybrid keyword + vector search, always filtered by tenant_id, top ~10 chunks after reranking.
  5. It calls the model gateway. The model either answers, asks a follow-up, requests a tool call, or requests escalation (escalation is itself a tool). Tool calls go to the actions service; results feed back into the loop, capped at ~5 iterations and ~30s.
  6. Tokens stream back through the conversation service to the customer's socket. Before committing the final message (and before executing any mutating tool), the orchestrator re-checks the control flag — this is the gate that makes mid-turn human takeover clean.

The pieces that deserve their design time

Conversation service and takeover

Each conversation is a small state machine: agent_active → human_active → resolved/expired, with a control field (agent | human) that a human can flip at any time from the console. Takeover does three things: sets the flag in Redis and the durable store, cancels any in-flight orchestrator turn for that conversation (the worker checks the flag at every commit point — before each tool execution and before sending), and pushes the full transcript plus an auto-generated summary to the human's console. Escalation is the same mechanism initiated by the agent: it enqueues the conversation into the business's agent queue (routed by skill/language), posts a "connecting you to a person" message, and the customer keeps the same chat session — the handoff is invisible at the transport layer.

Why a control flag checked at commit points, rather than trying to kill the LLM call instantly? Because the race is unavoidable — the model may already be mid-generation when the human clicks takeover — and what actually matters is that no agent message lands and no action executes after takeover. The flag check at the two commit points guarantees exactly that, cheaply.

Model gateway

One internal service fronts all external providers. It holds provider keys, enforces per-tenant token budgets and rate limits, meters usage for billing, and handles retries and failover. Routing policy: a small/cheap model for intent classification and the "does this even need retrieval?" decision; the strong model for answer generation and tool use. Prompt structure puts the static parts first (platform system prompt, then per-business instructions and tool schemas, then history) so provider-side prompt caching hits — with 60B input tokens/day, cache hit rate is a first-order cost lever, plausibly a 50–80% reduction on input cost.

Failover ladder when a provider degrades: retry once → switch provider (we keep prompts provider-portable and pre-provision capacity on at least two) → if both are down, the agent stops pretending: the conversation service sends a templated "we're connecting you with a person / we'll email you" message and escalates. The chat, takeover, and human paths share none of the LLM dependency chain, so a total provider outage turns the product into a plain human-support chat instead of an error page.

Retrieval and the 5-minute freshness requirement

Ingestion: businesses connect sources (help-center crawls, uploaded docs, APIs). A pipeline normalizes → chunks (~500 tokens, overlapping, with heading metadata) → embeds → upserts into the index, with tombstones for deleted docs. The 5-minute SLA is what forces this to be a streaming upsert pipeline rather than a nightly index rebuild: change events flow through a queue, and embedding a changed document is seconds of work. The SLA is met per-document; a business re-uploading its entire 100K-chunk KB at once gets eventual catch-up, not 5 minutes, and I'd state that openly.

Index layout: one shared index, sharded by tenant_id, with the tenant filter applied inside the engine (filtered HNSW / partition routing), not as a post-filter. Per-tenant indexes would give hard isolation but 10,000 mostly-tiny indexes are an operational mess; a shared post-filtered index risks cross-tenant leakage if a filter is ever dropped. Sharding by tenant gives physical locality, and I'd add a belt-and-suspenders check: the retrieval service verifies tenant_id on every returned chunk before handing it to the orchestrator. The few whale tenants get dedicated shards.

Search is hybrid — BM25 plus vectors, fused, then a lightweight reranker — because support queries are full of exact strings (SKUs, error codes, plan names) that pure embedding search misses.

Actions service: the model proposes, this service disposes

The security stance: the model is untrusted input handling untrusted input. KB content and customer messages can both contain prompt-injection attempts, so nothing the model says is treated as authorization. The actions service independently enforces:

Connectors are the multi-tenant reality tax: pre-built adapters for the big platforms (Shopify, Stripe, Zendesk) plus a generic "bring your OpenAPI spec

APIs (external, abbreviated)

POST /v1/conversations                     → {conversation_id, ws_url}   # customer JWT
WS   /v1/ws?token=…                        # bidi: send message, receive tokens/events
GET  /v1/conversations/{id}/messages?after=seq   # history / reconnect backfill

POST /v1/conversations/{id}/takeover       # human console
POST /v1/conversations/{id}/messages       # human reply
POST /v1/conversations/{id}/release        # hand back to agent

PUT  /v1/kb/sources/{id}                   # register/update a KB source
POST /v1/kb/documents                      # push a document (upsert)
PUT  /v1/tools/{name}                      # tool config: schema, mapping, policy
GET  /v1/analytics/…                       # deflection rate, CSAT, escalations

WebSocket for the chat because it's bidirectional (typing indicators, human takeover events pushed to the client), with SSE + polling fallback for restrictive networks. Reconnects resume via after=seq — sequence numbers make the client idempotent to redelivery.

Storage

Data Store Why
Messages, turns Wide-column / DynamoDB-style, PK conversation_id, SK seq Write-heavy, always accessed by conversation; per-partition ordering gives per-conversation ordering for free
Active conversation state, control flag, presence Redis Read on every turn, sub-ms, TTL cleans up abandoned conversations
Tenants, tool configs, policies, prompts (versioned) Postgres Small, relational, transactional; cached in-process with a short TTL
KB chunks + embeddings Sharded hybrid index (e.g., OpenSearch or a vector DB with filtering) 50M chunks, streaming upserts, filtered ANN
Traces, transcripts > 90 days Object storage (Parquet) 7TB+/year; queryable for the 1-year retention, then lifecycle-deleted
Action audit log Append-only (same wide-column family, separate table) Compliance; never overwritten

Consistency: the message append is the source of truth and is strongly consistent per conversation (single partition). Everything downstream — queue, orchestrator, analytics — is at-least-once, made safe by sequence numbers and idempotency keys. I'd rather re-process a turn than lose one.

Scaling and failure handling

Security and safety

Tradeoffs I'd call out explicitly