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
┌─────────────────────┐
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 usuallysourceId) 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
- Customer adds a base URL, sitemap, or connected source.
- API enqueues a crawl job on Queues with tenant + source + cursor/priority.
- Crawl consumer fetches pages with budgets (max pages, concurrency, robots/terms respect).
- Normalized HTML/Markdown lands in R2 as the object source of truth.
- Chunker splits content into retrieval units with stable IDs.
- Workers AI embeddings write vectors into Vectorize with metadata:
tenantIddocId/pathtitlesourceHash(for invalidation)
- 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
// 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:
- partial answer + clear gaps, or
- 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:
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.