← Back to Blog

How I Built UseDocs on Cloudflare: RAG, Agents, and Multi-Tenant Edge

#cloudflare#usedocs#rag#ai#workers#durable-objects#vectorize#saas

UseDocs is an AI support platform I built as founder/full-stack: customers connect documentation, end users ask questions, and the product answers with citations — or escalates honestly when it shouldn’t guess.

This post is the architecture story: why Cloudflare, how the pieces fit, and the product decisions that shaped the system.

The product in one paragraph

A team pastes or connects their docs. UseDocs crawls and resyncs content, embeds it for semantic search, and exposes:

  • a dashboard for knowledge, chats, analytics, and docs-ops
  • an embeddable widget for sites and apps
  • optional Slack / Discord / Teams delivery
  • usage metering and billing so multi-tenant SaaS isn’t an afterthought

Core loop: crawl → chunk → embed → retrieve → answer with citations → measure gaps → improve docs.

Why Cloudflare for an AI SaaS

I didn’t pick Cloudflare because it was trendy. I picked it because the product shape maps cleanly onto their primitives:

| Product need | Cloudflare primitive | |--------------|----------------------| | Global low-latency API + widget | Workers | | Per-tenant coordination / sessions | Durable Objects | | Background crawl & resync | Queues | | Doc/object storage | R2 | | Relational tenant data | D1 | | Embeddings + semantic search | Workers AI + Vectorize | | Edge inference | Workers AI | | Frontend delivery | Workers Assets / Pages |

One account, one deploy model, and bindings instead of a spaghetti of VPC + IAM + five managed services on day one. For a zero-to-one product, that matters.

High-level architecture

txt
                    ┌─────────────────────┐
   Dashboard SPA ──►│  app host (assets)  │
                    └──────────┬──────────┘
                               │
                    ┌──────────▼──────────┐
   Widget / bots ──►│  API Worker (Hono)  │◄── Better Auth sessions
                    └──────────┬──────────┘
           ┌───────────────────┼───────────────────┐
           ▼                   ▼                   ▼
        D1 (tenants,       Vectorize            R2 (raw docs,
        chats, usage)      (chunks +            snapshots)
                           embeddings)
           │                   ▲
           │                   │
           ▼                   │
     Queues: crawl ──► crawl Worker ──► Workers AI embeddings
           │
           ▼
   Durable Objects (tenant locks, live widget state, rate windows)

Not every request touches every box. Chat retrieval is the hot path: auth → tenant scope → Vectorize query → LLM answer → log chat + usage. Crawl is async. Billing is event-driven off metered usage.

Stack (what actually shipped)

Edge / backend

  • Cloudflare Workers
  • Agents SDK (agent-style orchestration for tool use and multi-step support flows)
  • Durable Objects
  • Workers AI
  • Vectorize
  • R2
  • D1
  • Queues

App

  • React + TypeScript + Vite
  • Better Auth for secure session and access control
  • Dodo Payments for subscriptions / customer billing

The frontend is intentionally boring. The interesting system is the multi-tenant RAG pipeline and the edge runtime around it.

Multi-tenancy as a first-class constraint

Every feature starts with: which tenant is this, and are they allowed?

  • Tenant identity is resolved from session, API key, or widget bootstrap token — never from a client-supplied “tenant id” alone.
  • D1 rows carry tenant_id.
  • R2 keys are prefixed: tenants/{tenantId}/docs/...
  • Vectorize metadata includes tenantId (and usually sourceId) so retrieval filters can’t bleed across customers.
  • Durable Object IDs are derived from tenant (or tenant + channel) so coordination stays isolated.

If you only remember one thing from this post: RAG bugs that cross tenants are company-ending bugs. Scope every store.

Ingestion: crawl, resync, and object truth

Documentation isn’t a single PDF upload. It’s a living tree of pages, versions, and “we rewrote onboarding last week.”

Crawl pipeline

  1. Customer adds a base URL, sitemap, or connected source.
  2. API enqueues a crawl job on Queues with tenant + source + cursor/priority.
  3. Crawl consumer fetches pages with budgets (max pages, concurrency, robots/terms respect).
  4. Normalized HTML/Markdown lands in R2 as the object source of truth.
  5. Chunker splits content into retrieval units with stable IDs.
  6. Workers AI embeddings write vectors into Vectorize with metadata:
    • tenantId
    • docId / path
    • title
    • sourceHash (for invalidation)
  7. D1 updates source status: idle | crawling | ready | error, last sync time, page counts.

Resync without rewriting the world

Blind re-embeds are expensive and slow. On resync:

  • compare content hashes
  • delete/re-embed only changed chunks
  • tombstone removed paths so answers don’t cite deleted pages

Queues make this resilient: a failed page doesn’t kill the whole tenant crawl.

Retrieval and answers: cited or escalate

The chat path is optimized for honesty, not vibes.

Retrieve

typescript
// Pseudocode for the hot path
const tenant = await requireTenant(c);
const query = await embedQuery(c.env.AI, userMessage);

const matches = await c.env.VECTORIZE.query(query, {
  topK: 8,
  filter: { tenantId: tenant.id },
  returnMetadata: true,
});

Generate with citations

The model only sees retrieved chunks plus a system contract:

  • answer from provided context
  • cite paths/titles for claims
  • if context is thin or conflicting, say so

Honest escalation

Low confidence isn’t a UI flourish — it’s a product requirement. Signals I combine:

  • weak retrieval scores / large score drop after top-1
  • model self-reported uncertainty (structured output)
  • missing required entities (pricing, policy dates) in context
  • user explicitly asking for human help

When confidence is low, UseDocs prefers:

  1. partial answer + clear gaps, or
  2. escalation to human channels (Slack/Discord/Teams / ticket hooks)

Wrong answers destroy trust faster than “I’m not sure — here’s who can help.”

Durable Objects: where edge state lives

Workers are stateless. Some support problems are not:

  • per-tenant crawl locks so two resyncs don’t stomp each other
  • widget session coordination for streaming / presence-ish state
  • rate windows for abusive or runaway clients
  • agent run state when multi-step tool flows need coordination

A Durable Object per tenant (or per tenant channel) keeps that logic single-threaded and close to the user without standing up Redis on day one.

Use DOs for coordination, not as your primary document database. Docs and vectors stay in R2 + Vectorize + D1.

Agents SDK: multi-step support, not just chat completions

Simple Q&A is “retrieve → complete.” Real support often needs tools:

  • search docs again with a refined query
  • fetch a specific page by path
  • open an escalation
  • summarize a thread for a human

The Agents SDK fits this on Workers: an agent run with tools, bounded steps, and tenant-scoped credentials. I keep tools boring and auditable — no open-ended “run arbitrary code” in production tenancy.

Auth and payments on the same product spine

Better Auth

Dashboard users need sessions, org membership, and role-aware access (owner vs member vs read-only). Better Auth sits in front of tenant admin APIs; Workers validate session cookies/tokens the same way you’d validate JWT middleware elsewhere — just at the edge.

Widget traffic is different: short-lived bootstrap tokens or public keys scoped to a tenant + origin allowlist, not full dashboard sessions.

Dodo Payments

SaaS without metering is a demo. Usage events (messages, crawl pages, seats — whatever you bill) write to a ledger the billing layer can invoice against. Dodo handles checkout/subscription lifecycle; the Worker owns usage ingestion and plan gates (“crawl paused — upgrade”).

Plan limits are enforced in the API Worker before expensive AI/crawl work, not only in the UI.

Docs-ops loop: the product behind the product

Answering questions is half the job. The other half is telling teams what their docs fail to answer.

UseDocs clusters low-confidence / no-hit chats into gaps, produces digests, and can draft starter doc updates from gap clusters. That loop turns support noise into a content backlog:

txt
chat logs → gap clustering → digest → gap→draft → human publish → resync

Analytics in the dashboard close the loop: which sources convert to answers, which paths are cited, where users abandon.

Frontend: Vite + React, not a mega-Next deploy

The dashboard is React + TypeScript + Vite. It talks to the API Worker, handles auth client flows, and renders knowledge/chat/analytics views. Shipping the UI as static assets (Workers Assets or Pages) keeps the expensive, stateful intelligence on the Worker side where bindings live.

That split is intentional: UI iterates fast; edge owns tenancy, AI, and money.

Observability and failure modes

Edge RAG fails in specific ways. Instrument for them:

  • crawl success rate / pages per source
  • embed latency and Vectorize query latency
  • citation coverage (% answers with ≥1 citation)
  • escalation rate by tenant
  • auth failures vs plan-limit 402/429s
  • queue lag (resync freshness)

When Vectorize is empty for a tenant, the product should say “docs not ready,” not invent onboarding steps from the base model.

What I’d repeat (and what I’d watch)

Repeat

  • Tenant scope in every store from day one
  • Async crawl on Queues, not request-path scraping
  • Citations + escalation as product features, not prompt footnotes
  • Separate widget auth from dashboard auth
  • Meter before you give away unbounded AI

Watch

  • Chunking quality beats model upgrades for doc Q&A
  • Custom domains and CORS for embeds need explicit origin policy
  • D1 is great early; know your growth plan for heavy analytics
  • Agent tools need hard budgets (steps, tokens, time)

Closing

UseDocs is a bet that multi-tenant AI support can live at the edge — not as a thin proxy to a single GPU box, but as a real product: crawl, retrieval, answers, billing, and docs-ops on Cloudflare’s primitives.

If you’re building something similar, start with tenancy and the ingestion pipeline, not the chat UI. The widget is the tip of the iceberg; R2, Vectorize, Queues, and honest failure modes are the mass under the water.

Related: Cloudflare dynamic routes — how path/host routing works when you split api., widgets, and app hosts like this.

Go Home