Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save theletterf/c650bcdbc94c985a3ed5b8e76e863ae6 to your computer and use it in GitHub Desktop.

Select an option

Save theletterf/c650bcdbc94c985a3ed5b8e76e863ae6 to your computer and use it in GitHub Desktop.
A small AI chatbot on Cloudflare Workers and OpenRouter

A small AI chatbot on Cloudflare Workers and OpenRouter

This is a practical architecture for adding a chat interface to a static site without operating a server. It uses a Cloudflare Worker to serve the static site and provide one API endpoint, OpenRouter for model access, Cloudflare Turnstile for abuse challenges, and a Durable Object for per-visitor rate limiting.

It is suitable for a personal site, portfolio, documentation site, or small public knowledge assistant. It is not an invitation to expose an uncapped model key to the internet.

What you need

  • A static site build, such as Hugo, that produces public/.
  • A Cloudflare account and a Worker project.
  • A Cloudflare Turnstile widget for the production hostname.
  • An OpenRouter account, a dedicated API key, and a strict spend limit.
  • Node.js and Wrangler (npx wrangler is sufficient).
  • A source of grounded content. This example uses an llms.txt index and Markdown mirrors for posts.

Cloudflare Workers Free currently includes 100,000 requests per day and supports SQLite-backed Durable Objects. Turnstile has a free plan. These limits and product terms change, so check the official pricing pages before relying on them. OpenRouter model usage is a separate cost: select a model deliberately and cap the key's credit or monthly budget.

Architecture

Browser
  | POST /api/chat { messages, turnstileToken? }
  v
Cloudflare Worker
  |-- serves ./public static assets
  |-- validates and limits requests
  |-- fetches relevant local content
  `-- calls OpenRouter /api/v1/chat/completions
        |
        `-- model provider

Keep the OpenRouter key in the Worker only. The browser sends user messages to your Worker, never to OpenRouter directly.

1. Configure the Worker

wrangler.toml can serve your static build and bind a rate limiter:

name = "my-site-chat"
compatibility_date = "2026-07-19"
main = "src/worker.js"

[assets]
directory = "./public"
binding = "ASSETS"
run_worker_first = true

[vars]
TURNSTILE_SITE_KEY = "your-public-site-key"

[durable_objects]
bindings = [
  { name = "CHAT_LIMITER", class_name = "ChatLimiter" }
]

[[migrations]]
tag = "v1"
new_sqlite_classes = ["ChatLimiter"]

Keep public configuration, such as the Turnstile site key, in [vars]. Put secrets in Cloudflare instead:

npx wrangler secret put OPENROUTER_API_KEY
npx wrangler secret put TURNSTILE_SECRET_KEY
npx wrangler secret put CHAT_IP_SALT

Use a dedicated OpenRouter key with a small monthly cap. Do not put any provider key in client JavaScript, wrangler.toml, Git, or a static-site environment file.

2. Validate requests before calling the model

At minimum, enforce all of the following in the Worker:

  • Accept only POST and application/json.
  • Cap request bytes, user-message length, and retained history.
  • Require the final retained message to be from the user.
  • Return Cache-Control: no-store for chat responses.
  • Rate limit by a salted hash of CF-Connecting-IP, never a raw stored IP.
  • Require Turnstile after a small unauthenticated allowance, then verify its token server-side.
  • Set an OpenRouter budget and return a useful error for exhausted credit.

A simple salted hash avoids putting raw IP addresses in Durable Object IDs:

async function visitorKey(request, env) {
  const ip = request.headers.get("CF-Connecting-IP") || "unknown";
  const bytes = new TextEncoder().encode(`${env.CHAT_IP_SALT}:${ip}`);
  const digest = await crypto.subtle.digest("SHA-256", bytes);
  return [...new Uint8Array(digest)]
    .map((byte) => byte.toString(16).padStart(2, "0"))
    .join("");
}

Use one Durable Object instance per hash. Store only counters and UTC-day/window timestamps, not transcripts. A reasonable initial policy is a few requests per ten minutes before Turnstile and a conservative per-day ceiling after verification.

3. Ground the model in site content

Do not send a vague system prompt and hope for an informed assistant. Retrieve relevant content from your own site on each request.

For a small blog, an effective low-complexity pattern is:

  1. Publish llms.txt containing Markdown links and short summaries of posts.
  2. Extract the links in the Worker.
  3. Score titles and summaries against the current question.
  4. Fetch the top two or three Markdown documents from the asset binding.
  5. Include their title, canonical page URL, and a bounded excerpt in the model context.

This needs no vector database. Move to a build-time index, Workers KV, Vectorize, or embeddings only when simple lexical retrieval demonstrably stops finding the right content.

Require the model to distinguish source material from inference and to use ordinary page links when it cites a post. Keep the system prompt short enough that actual retrieved content remains the important part of the request.

4. Call OpenRouter from the Worker

OpenRouter accepts an OpenAI-compatible chat-completions request. Keep the model name in one constant and select it according to quality, latency, and budget.

const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${env.OPENROUTER_API_KEY}`,
    "Content-Type": "application/json",
    "HTTP-Referer": new URL("/", request.url).toString(),
    "X-Title": "My Site Assistant"
  },
  body: JSON.stringify({
    model: "provider/model-name",
    messages: [
      { role: "system", content: systemPromptWithContext },
      ...validatedMessages
    ],
    stream: false,
    temperature: 0.4
  })
});

Do not impose a provider output-token ceiling unless you accept that it can cut an answer off mid-sentence. If you need concise answers, express a character and sentence limit in the system prompt and test the model's compliance.

5. Render model output safely

Treat every model response as untrusted content. Plain text is the safest option. If you render Markdown, use a maintained parser plus an HTML sanitizer, for example marked followed by DOMPurify, and allow only the elements and URL schemes your UI needs.

Support only the Markdown features you intend to display. For a compact chatbot, paragraphs, links, emphasis, and short lists are usually enough. Never interpolate raw model output into innerHTML without sanitization.

6. Turnstile flow

  1. The browser loads the public Turnstile site key from a small Worker config endpoint.
  2. Render an invisible or managed Turnstile widget only when the Worker requests verification.
  3. Send its token with the retry request.
  4. The Worker calls https://challenges.cloudflare.com/turnstile/v0/siteverify using TURNSTILE_SECRET_KEY.
  5. On success, allow the retry. On failure, return an ordinary error and do not call OpenRouter.

Turnstile is an abuse-control layer, not an authorization system. Keep the rate limiter even when it is enabled.

7. Deploy

hugo --environment production
npx wrangler deploy

Test the static site, the chat endpoint, rate limits, a failed Turnstile token, an exhausted OpenRouter key, and a prompt that attempts to override system instructions. Monitor Worker and OpenRouter usage after launch.

Operational checklist

  • Dedicated, capped OpenRouter API key.
  • No secrets in Git or browser code.
  • Server-side Turnstile verification.
  • Per-visitor, salted-hash rate limit.
  • Strict request and history limits.
  • Grounded context from site-owned documents.
  • Sanitized Markdown or plain-text rendering.
  • Budget, error, and abuse-path tests.
  • Current Cloudflare quota and OpenRouter pricing checked before launch.

Official references

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment