DEV Community

Cover image for Build a support agent on Cloudflare Workers that cites its sources
Ibrahim Hajjaj
Ibrahim Hajjaj

Posted on

Build a support agent on Cloudflare Workers that cites its sources

Most "AI support agent" tutorials end with a chat box that confidently makes something up. The fix is not a better prompt. It is making the agent show which page it got the answer from, so the person reading can check it.

This walks through putting one on Cloudflare Workers: what the code is, and the four things that broke when I did it. The gotchas are the useful half.

Everything here is from recourse, which is MIT and self-hosted, but the Workers-specific parts apply to anything you build on this runtime.

The handler is already a Worker

A Worker is a function from a Request to a Response. If your chat handler is written against Request, Response, fetch and Web Crypto, there is nothing to adapt:

import { createChatHandler } from '@recourse-ai/core/server'
import { models } from '@recourse-ai/core/models'
import knowledge from './knowledge.json'

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const handler = createChatHandler({
      index: knowledge,
      model: models.fromEnvironment(env),
      embedder: false,
      persona: {
        name: 'Ada',
        business: 'Lumen Coffee Roasters',
        fallback: "I can't find that in our help pages.",
      },
    })
    return handler(request)
  },
}
Enter fullscreen mode Exit fullscreen mode

The index is imported rather than fetched, so it is bundled with the Worker and a cold start is a JSON parse. No warm-up, no vector database to reach across the network before you can answer the first question.

The wrangler.jsonc is short, and what is missing from it is the point:

{
  "name": "recourse-worker",
  "main": "src/index.ts",
  "compatibility_date": "2026-08-01",
  "observability": { "enabled": true }
}
Enter fullscreen mode Exit fullscreen mode

No nodejs_compat. No compatibility flags at all.

Gotcha 1: import subpaths, not the root

This is the one that gets everybody, because the failure is a bundle size and a polyfill rather than an error you can read.

The package root re-exports ingest, which reads documents off disk, which imports node:fs. Import the root on a Worker and you drag the filesystem into a runtime that has no filesystem.

// wrong: pulls node:fs through the ingest re-export
import { createChatHandler } from '@recourse-ai/core'

// right: none of these touch the filesystem
import { createChatHandler } from '@recourse-ai/core/server'
import { models } from '@recourse-ai/core/models'
import { createAgent } from '@recourse-ai/core/agent'
Enter fullscreen mode Exit fullscreen mode

Worth automating rather than remembering. A build step that greps the bundle for Node built-ins and fails on a hit costs twenty lines and catches this the first time somebody adds an import:

// check-bundle.mjs, run in CI
const NODE_BUILTINS = /\bnode:(fs|path|crypto|stream|buffer|os)\b/
if (NODE_BUILTINS.test(bundle)) {
  throw new Error('a Node built-in reached the Worker bundle')
}
if (bundle.length > 200_000) {
  throw new Error(`bundle is ${bundle.length} bytes, budget is 200KB`)
}
Enter fullscreen mode Exit fullscreen mode

For reference the working bundle is 129.4 KB with no Node built-ins. When that guard fires it is telling you something Node-only leaked onto the serving path, which is a design signal and not just a size complaint.

Gotcha 2: process throws, it does not return undefined

On a Worker there is no process global. Reading it is a ReferenceError, not undefined, so the usual defensive pattern does not save you:

// throws on a Worker. It does not evaluate to the fallback.
const key = process.env.API_KEY ?? 'fallback'
Enter fullscreen mode Exit fullscreen mode

The environment arrives as the second argument to fetch, per request, and anything that reads configuration has to be handed it:

export default {
  async fetch(request: Request, env: Env) {
    return createChatHandler({
      index,
      model: models.fromEnvironment(env),
    })(request)
  },
}
Enter fullscreen mode Exit fullscreen mode

Note that the handler is built inside fetch rather than at module scope. At module scope there is no env yet.

This one has a nasty second-order version. If you leave the model unset it can fall back to a hosted gateway, and that provider reads process.env internally. So you get process is not defined at request time, which reads like a bundling problem, when the actual cause is a missing configuration value. Pass an explicit model and the error goes away.

Gotcha 3: wrangler dev does not forward your shell

Correct behaviour, and surprising exactly once. Your exported variables are not visible to the Worker. They go in .dev.vars, which you git-ignore:

OPENAI_COMPATIBLE_BASE_URL = "http://localhost:11434/v1"
OPENAI_COMPATIBLE_MODEL = "qwen3:4b"
OPENAI_COMPATIBLE_API_KEY = "ollama"
Enter fullscreen mode Exit fullscreen mode

That points at a local Ollama, so you can develop the whole thing without an API key or a bill. In production the same three variables point at whatever OpenAI-compatible endpoint you like, including Workers AI.

Citations, which is the actual point

Retrieval returns passages, and each passage remembers which document it came from. Keep that association through to the answer and render it, rather than flattening the passages into one blob of context:

To hand a conversation to a person, you can use the escalate function,
which marks the conversation as belonging to a person [1].

[1] Handing a conversation to the desk you already run
    · Getting out of the way once a person arrives
Enter fullscreen mode Exit fullscreen mode

The reason to care is not neatness. An answer with a source is falsifiable: the reader can open the page and see you are wrong. An answer without one has to be taken on trust, and support answers taken on trust are how people end up following instructions for the previous version of your product.

This also runs with no API key at all. With no embedder configured, retrieval is BM25 keyword matching, which needs no credential and no vector store. You get worse recall than hybrid search, and you get it before signing up for anything, which is the right trade for a first run.

Adding D1, and the limit that will catch you

Conversations go in D1 through a binding, so there is no connection pool, no credential and nothing to exhaust:

{
  "d1_databases": [
    { "binding": "DB", "database_name": "recourse", "database_id": "..." }
  ]
}
Enter fullscreen mode Exit fullscreen mode
import { d1Store } from '@recourse-ai/store-d1'

const store = d1Store({ db: env.DB })
Enter fullscreen mode Exit fullscreen mode

The free tier's limit is 50 queries per invocation, not per day. Per invocation. A handler that runs a query per retrieved chunk can reach it inside one request while your dashboard shows almost no usage for the day, so the failure reads as an intermittent bug rather than a quota. Batch reads, or keep the index bundled and use D1 only for conversation state.

One check worth running before a customer does

Every credential here is passed as an option rather than read from a global, which is the right shape and has one cost: nothing validates it until a webhook arrives and fails. A wrong signing secret looks exactly like silence.

npx @recourse-ai/core doctor
Enter fullscreen mode Exit fullscreen mode
  FAIL  embedding model  the index was built with "nomic-embed-text" but the
                         environment says "mxbai-embed-large"
  ok    index            28 chunks from 6 documents, hybrid
  ok    model            "qwen3:4b" is available
Enter fullscreen mode Exit fullscreen mode

That first line is the check worth having on its own. A query vector from one embedding model compared against stored vectors from another is not a comparable number. Nothing errors. You just get quietly bad answers, and you go looking at your prompt.

What this does not solve

Being honest about the shape of it, because "self-hosted" is not free:

  • You are on call. A hosted tool has someone else on call.
  • There is no dashboard to hand a support lead. Configuration is code in your repository, which is the point if your refund flow does not fit a settings page, and a real cost if it does.
  • Keyword-only retrieval has worse recall than hybrid. Configure an embedder when you have one; just know it works before you do.

If none of that is a problem, the whole thing is a Request to a Response and a JSON file.

The repo is github.com/ibrahimhajjaj/recourse, MIT. There is a live demo answering from its own documentation at recourse-demo.ibrhajjaj.workers.dev if you want to try breaking it before you read any code.

Top comments (1)

Collapse
 
brianainews profile image
Brian · AI News

The falsifiable citation point is the real product here, not just the chat surface. Passing credentials through explicit options and keeping the handler Worker native also makes the deployment boundary easier to reason about. I would add a tiny test that rejects answers when every retrieved passage fails the query similarity check.