A chat assistant built on Cloudflare Workers AI, the Agents SDK, and Durable Objects. Users can attach images, connect their own MCP servers as tool add-ons, and chat over a resumable WebSocket connection.
public/index.html + index.js Vanilla JS frontend (no framework/build step)
│ WebSocket /agents/chat-agent/:instanceId
▼
src/index.ts Worker entry point (routing, health, CORS)
│ routeAgentRequest()
▼
src/lib/durable.ts ChatAgent — Durable Object (extends AIChatAgent)
│ streamText()
▼
Workers AI binding (env.AI) Kimi K2.6 (research) / Kimi K2.7-code (coding)
src/index.ts— thinfetchhandler. Hands off torouteAgentRequestfor all/agents/*traffic (WebSocket upgrades, RPC calls, MCP OAuth callbacks), and serves/healthfor basic liveness checks. Static assets underpublic/are served directly by the Workers Assets binding.src/lib/durable.ts— theChatAgentDurable Object. One instance per browser session (instanceId, persisted inlocalStorage). Streams model responses via the AI SDK'sstreamText, exposes@callable()RPC methods (addServer,removeServer,setModelLane) that the frontend invokes over the WebSocket, and picks a model lane automatically based on which MCP servers are connected (a GitHub-flavored server routes to the coding model; everything else uses the research model). Users can override the lane withsetModelLane('coding' | 'research' | 'auto').src/lib/context.ts— bounds the conversation history replayed to the model each turn. Full history always stays in Durable Object storage and is what the UI renders; this only caps what the model sees, which otherwise grew without limit — inflating cost quadratically and degrading answers as the current question got buried under stale context. WindowsUIMessages beforeconvertToModelMessages, since conversion expands one tool call into anassistant+toolpair that must not be split.src/lib/conversations.ts+src/lib/api.ts— the cross-device conversation list, stored in D1 (DBbinding). Message bodies stay in each ChatAgent Durable Object; D1 holds one index row per conversation (title, preview, count, timestamps) keyed by the verified owner, so the web client and the Android app can list, resume, rename and delete the same threads.ChatAgent.persistMessageskeeps the row current (off the hot path, viawaitUntil), andonConnectpushes the stored transcript to every newly connected client so reopening a thread on another device shows its history.src/lib/utils.ts— small shared helpers (CORS headers, JSON responses, error formatting).public/— a dependency-free frontend: capped-retry WebSocket reconnection, client-side image compression before upload, and an "Add-ons" panel for connecting/removing MCP servers at runtime.public/markdown.js— the hand-rolled Markdown renderer, kept in its own module so its escaping logic is unit-testable without a DOM. Output is assigned viainnerHTML, soescHtml()andsafeUrl()are a security boundary: model output is untrusted (MCP tool results flow straight into it).public/_headersadds a CSP as defence in depth.test/— Vitest unit tests. The two suites cover the code paths where a silent regression is most costly: the streaming placeholder filter (sanitize.ts) and the Markdown renderer's XSS defences.
Tools are not hard-coded — anything the user connects through the
Add-ons panel (addServer(name, url, token?)) becomes available to the model
automatically via this.mcp.getAITools().
- Node.js 22+ (AI SDK 7 requirement; enforced via
engines) - A Cloudflare account with Workers AI enabled
npm installworker-configuration.d.ts is generated, not committed. npm run typecheck
and npm run build both regenerate it first, so a fresh clone typechecks
without any extra step. Run npm run types by hand after editing bindings in
wrangler.jsonc if you want your editor to pick them up immediately.
npm run dev # wrangler dev — http://localhost:8787Open public/index.html in a browser pointed at that origin (or just hit the
worker's dev URL directly — it serves the assets too).
npm run typecheck # wrangler types && tsc --noEmit
npm test # vitest run
npm run test:watch # vitest (watch mode)
npm run build # wrangler deploy --dry-run --outdir=dist (bundle check, no deploy)
npm run check # all three, in order — run this before opening a PRnpm run deployOn first deploy, wrangler auto-provisions the insertabot-conversations D1
database, and the Worker creates its table on first use (npm run db:migrate:remote applies migrations/ explicitly). Pin the resulting
database_id in wrangler.jsonc afterwards.
All routes require the same Cloudflare Access auth as /agents/* and are
scoped to the caller. GET /health reports features.conversations: true
when the index is available.
| Method | Path | Notes |
|---|---|---|
GET |
/api/conversations?limit=30&cursor= |
{ conversations: [{id,title,preview,messageCount,createdAt,updatedAt}], nextCursor }, newest first |
GET |
/api/conversations/:id |
one entry |
PATCH |
/api/conversations/:id |
{"title": "..."}, which pins the title against auto-titling |
DELETE |
/api/conversations/:id |
wipes the Durable Object (messages, MCP, memory) and the index row |
To open a conversation, connect the WebSocket to /agents/chat-agent/:id as
before. The agent sends cf_agent_chat_messages with the full transcript on
connect.
Browsers resolve to the user's email, while the Android app's Access service
token resolves to svc:<clientId>. Those are different owners, so the two
see separate conversation lists unless the token is linked:
npx wrangler secret put SERVICE_TOKEN_OWNERS
# {"<service-token-client-id>.access": "you@example.com"}Deploys to the custom domain configured in wrangler.jsonc
(cfworker.insertabot.io).
| Setting | Where | Notes |
|---|---|---|
SYSTEM_PROMPT |
wrangler.jsonc → vars |
Override with wrangler secret put SYSTEM_PROMPT for a value that shouldn't be committed. |
| Model lanes | src/lib/durable.ts → MODEL_LANES |
Add/rename lanes and the heuristic in inferLane() here. |
| Context window | src/lib/context.ts → DEFAULT_CONTEXT_WINDOW |
maxTokens is the replayed-history budget; minRecentMessages is a floor so one huge message cannot starve the window; maxMessages is a hard cap. Raise maxTokens for longer recall at higher cost per turn. |
| MCP servers | Runtime, via the Add-ons UI | Nothing to configure ahead of time — each browser session connects its own servers, persisted per Durable Object instance. |
- This project's knowledge of Cloudflare Workers/Agents/AI SDK APIs can go
stale fast — see
AGENTS.mdbefore touching bindings, MCP, or Durable Object code. sanitize.tssits on the main text stream. Any whitespace normalisation there must stay local to a removed placeholder. A global whitespace collapse looks harmless but flattens the indentation of every fenced code block the model streams — and because the transform runs beforeonFinish, the mangled text is what gets persisted.test/sanitize.test.tsguards this.markdown.jsrenders untrusted model output viainnerHTML. Escape quotes as well as&<>(anhrefis built from a captured group), and keep link targets behindsafeUrl()'s scheme allowlist.- Window history before
convertToModelMessages, never after. Conversion expands a single tool call into anassistant+toolmessage pair, so slicing the converted list can orphan atoolresult whose originating tool-call is gone — which providers reject.test/context.test.tsasserts everytoolmessage is still preceded by anassistantone. - Replaying the full history is not free. Input tokens are re-billed every
turn, so an uncapped history costs quadratically in a long thread and buries
the current question under stale context. If recall needs to extend further
back, prefer summarising or retrieving over raising
maxTokens. - CORS is an allowlist (
src/lib/utils.ts→ALLOWED_ORIGINS), not a reflection of the caller'sOrigin. Add new browser origins there. Native clients send noOriginand never consult CORS, so this governs browsers only. - There is no PWA.
manifest.jsonandservice-worker.jswere removed — they were never referenced fromindex.html, and the Android app is a native Jetpack Compose client that talks to/agents/*over OkHttp rather than loading this HTML, so nothing consumed them. - Known gaps not yet addressed: the DO SQLite memory store in
src/lib/memory.tsis not reachable from the UI or the model (its@callable()RPCs remain part of the WebSocket API surface, so confirm no native client calls them before removing it);pruneMemoriesalways returns0andforget()reports false positives; and the client-side?plan=gating is cosmetic (the Worker never reads it). - Keep
src/index.tsandsrc/lib/durable.tsas the source of truth for architecture; older design docs describing a REST/v1/chat/completionsAPI with hard-coded Tavily/GitHub tools have been removed as they no longer reflect this codebase.