← Back to Home
🧠

SE Intel

A role-gated multi-agent revenue intelligence platform β€” pre-call research and sales coaching, grounded in a 102-chunk RAG knowledge base with streaming responses and cross-session memory.

View the full Build Log (14 study chapters, weekly theory insights) →

Live
Workers Durable Objects Workers AI Vectorize KV D1 Hono

Cycle 1 β€” Production Hardening (June–July 2026)

Everything below "What I Built" was the original prototype. Since then I've been running a weekly hardening cycle against this same system β€” each week answers one specific production question and ships a deterministic proof, not just a demo.

Week 1 β€” Multi-tenancy. Added a real orgId boundary across RAG (Vectorize metadata filter), memory (Durable Object keys scoped to orgId:userId, KV keys scoped to ltm:{orgId}:{userId}:{factId}), and audit (org-scoped, role-split reads). Three deterministic admin probes prove zero cross-org leakage β€” no LLM in the test path. Read the writeup β†’

Week 2 β€” Evals as a CI gate. The LLM-as-judge harness was scoring "groundedness" but missed a real bug: the KB chunk said a 35% discount, the agent answered 25%. Added a deterministic faithfulness check that compares what was actually retrieved against what the response says β€” separate from the LLM judge, so it can't be masked by the same hallucination it's hunting. The harness caught a second real, flaky bug on its first run. Read the writeup β†’

Week 3 β€” Observability + SLOs. Every agent request now writes a metric (latency, org, status) via waitUntil(), feeding a per-org health scorecard with p50/p95 latency, error rate, and error budget remaining. The interesting part: the initial p95 target (8000ms) was a guess β€” real 70B-model-with-RAG traffic measured 11-13 seconds. Recalibrated to 15000ms with the before/after numbers documented and a concrete improvement roadmap (KB answer caching, smaller-model routing for simple queries), instead of shipping a permanently-red dashboard.

Week 4 β€” Failure under load. Two fixes: (1) found a concurrency bug in the KV-based rate limiter β€” 25 concurrent requests only counted ~10. Moved the counter into a Durable Object where all requests are serialized (actor model, no locks needed). Verified: 20 concurrent requests counted exactly 20. (2) Built a 3-tier fallback chain: primary 70B model → fallback 8B model → canned degraded response. An X-Debug-Fallback header lets you demo degraded mode on demand. Full technical details in the build log →

The Problem

Enterprise sales teams spend 30-60 minutes on manual pre-call research for every prospect. Objection handling is inconsistent β€” different reps give different answers to the same customer question. And the technical knowledge that lives in internal playbooks never reaches the people who need it in front of a customer.

The standard AI assistant approach β€” one model, one chat box, same answer for everyone β€” ignores the fact that different roles should be asking different questions and getting different depths of answer.

What I Built

Two AI agents β€” AccountIntelAgent and EnablementAgent β€” each running as a Durable Object with its own embedded SQLite conversation history. Five roles (account_executive, solutions_engineer, csm, tam, manager) with JWT-gated access to three knowledge base namespaces: public (80 product chunks), technical (12 deep-dives), and leadership (10 deal strategy and pricing chunks).

Responses stream token-by-token via Server-Sent Events β€” tool badges appear before the first word, so the user sees what the agent retrieved before reading the answer. After every response, a lightweight LLM call extracts personal facts ("working on Stripe deal, 300 engineers") and stores them in KV β€” injected into the system prompt on every future conversation.

The system includes a Python evaluation harness: 15 test cases covering both agents and all five roles, an LLM-as-judge scorer (4 dimensions, 12-point scale), and a regression report that diffs against the previous run.

SE Intel dual-panel chat UI showing Account Intel and Enablement agents with streaming responses and tool badges

Architecture

Browser
  β”‚
  β”œβ”€ POST /api/v1/account/stream
  β”‚    Authorization: Bearer <HS256 JWT>
  β”‚
  β–Ό
Cloudflare Worker (Hono router)
  β”œβ”€ extractUserContext()  β†’ verify JWT β†’ UserContext{userId, role, orgId}
  β”œβ”€ checkRateLimit()      β†’ KV sliding window β†’ 429 if exceeded
  └─ stub.fetch("/stream") β†’ Durable Object for this userId
       β”‚
       β–Ό
  AccountIntelAgent (Durable Object β€” one per user)
  β”œβ”€ memory.getHistory()   β†’ SQLite: last 20 turns this threadId
  β”œβ”€ ltm.formatForPrompt() β†’ KV: stored facts about this user
  β”œβ”€ buildSystemPrompt()   β†’ role-specific + LTM injection
  β”œβ”€ dispatchTools()
  β”‚    β”œβ”€ fetchNews()      β†’ NewsAPI (se/tam/manager only)
  β”‚    β”œβ”€ kbSearch()       β†’ Vectorize RAG (namespace-filtered by role)
  β”‚    └─ webSearch()      β†’ DuckDuckGo β†’ KB fallback
  β”œβ”€ AI.run(stream: true)  β†’ Llama 3.3 70B β†’ ReadableStream
  β”œβ”€ SSE: tools β†’ tokens β†’ done
  └─ state.waitUntil()
       β”œβ”€ memory.append()  β†’ SQLite
       β”œβ”€ writeAudit()     β†’ D1
       └─ extractFacts()   β†’ Workers AI β†’ KV
Technical Deep Dive

Why Durable Objects for agent state

A Worker is stateless β€” every request might hit a different isolate. If a user sends two messages quickly, both requests could read the same conversation history in parallel and write back conflicting state. Durable Objects solve this: each user gets exactly one DO instance, single-threaded, with serialized request handling. Alice's agent can never run concurrently with itself. Each DO has its own embedded SQLite (10GB), so Alice's history is physically isolated from Bob's β€” not just logically separated by a key prefix.

Three-tier RAG with role gating

Queries are embedded with BGE-base-en-v1.5 (768 dims), searched against Vectorize with cosine similarity, and filtered by the user's allowed namespaces at query time. Access is enforced at three layers:

  • 1. ROLE_KB_ACCESS map in types/index.ts
  • 2. Re-check inside the kbSearch tool at execution time
  • 3. Vectorize filter: { namespace: ns } at the query layer

Defense in depth: if any one layer has a bug, the other two still enforce the constraint. Multiple namespaces are queried in parallel with Promise.all, results merged and sorted by score.

Streaming SSE

AI.run({ stream: true }) returns a ReadableStream. The DO's handler parses the Workers AI SSE format and re-emits three event types: tools (fires before first token, shows what was retrieved), token (one per token), done (latency + tools used). The Worker passes the DO's stream straight through to the browser with no buffering β€” TTFB under 100ms.

Long-term memory extraction

After every response, a short LLM call extracts personal facts from the exchange ("prefers bullet points", "working on Stripe deal") and writes them to KV via state.waitUntil(). Without waitUntil, the DO isolate terminates before async work completes β€” a silent failure. On subsequent requests, ltm.formatForPrompt() injects the stored facts into the system prompt automatically.

Evaluation harness

15 test cases across both agents and all roles. Notably: paired test cases ask the same question as two different roles β€” the judge's role_appropriateness dimension penalizes over-technical responses to non-technical roles and vice versa. LLM-as-judge scores 4 dimensions (0-3 each), pass threshold 8/12, regression report diffs against previous run.

Cloudflare Products Used

Workers β€” Hono router, JWT auth middleware, rate limiting, SSE stream passthrough, admin routes. Zero cold starts means streaming TTFB under 100ms.

Durable Objects (SQLite) β€” One DO per user per agent. Embedded SQLite for conversation history. Single-threaded execution eliminates race conditions on concurrent writes.

Workers AI β€” Llama 3.3 70B for chat generation (streaming), BGE-base-en-v1.5 for embeddings, Llama 3.3 70B again for memory extraction and eval judging. All free on the paid plan.

Vectorize β€” 102 vectors across 3 namespaces. Cosine similarity search with metadata namespace filtering enforces RBAC at the retrieval layer.

KV β€” Two namespaces: rate limit counters (TTL-keyed sliding window) and long-term user memory (fact store, evicts oldest beyond 50 facts).

D1 β€” Two tables: audit_log (userId, role, agent, tools used, org-scoped reads for compliance) and request_metrics (latency, status, agent type β€” feeds the per-org SLO health scorecard). Kept separate because one carries PII and one doesn't.

What I Learned

Why I Built This