A personal Discord bot and the small platform it runs on, built entirely on
Cloudflare Workers. One pnpm workspace where every deployed worker is its own
top-level apps/<worker> application and all shared code lives in
packages/*:
- The bot —
apps/gateway(Discord websocket + control routes),apps/ workflows(AI jobs + the deferred-command processor DO),apps/responder(Discord write policy),apps/spend(AI cost accounting). Domain code inpackages/discord. Commands:/rag,/ragboard,/raghammer,/ask,/bicture,/ragjam,/ragspend, … - The webhooks —
apps/webhooks(provider webhooks + Discord interaction callbacks atwebhooks.jsmunro.me); signatures are verified by the auth service (AUTH.verifyWebhook) / inline Ed25519. - The edge —
apps/authis the API Gateway: every public app binds it asAUTHand it owns all public authentication (Cloudflare Access, Better Auth Discord sessions, operator token) and the authorization policy table. Outbound HTTP is a plain in-process fetch (credential + timeout injected) in the method that needs it.
External edges are verified (Discord Ed25519, Cloudflare Access, provider webhook HMAC); public requests are authenticated and authorized by the auth worker, which backends trust and don't re-check. Worker-to-worker calls are plain, capability-gated Cloudflare service-binding RPC — no signing, no Cedar (only a worker whose wrangler declares a binding can make the call). Outbound credentials live on the workers that make the calls; webhook secrets on the auth worker. Architecture, conventions, and how to build things live in AGENTS.md.
apps/ one top-level dir per deployed worker:
auth, gateway, workflows, responder, spend, webhooks
packages/ shared (may never import apps):
edge-kit (the middleware), auth-kit (auth library),
discord, contracts-core, queue-kit, secrets, logger
scripts/ deploy, codegen, scaffold, dependency-direction check
migrations/ D1 schema (schema.sql is a read-only mirror)
test/ vitest (@cloudflare/vitest-pool-workers)
- Node 22+ (wrangler requires it) and pnpm.
op(1Password CLI) —.envholdsop://references; run project commands throughop run --env-file=.env --.brew install capnp— only if you change the wire schemas (packages/contracts-core/*.capnp).
Required env (via 1Password): DISCORD_APPLICATION_ID, DISCORD_PUBLIC_KEY,
DISCORD_BOT_TOKEN.
pnpm install
pnpm run check # tsc + dependency-direction check (packages↛apps, no cycles)
pnpm test # vitest, runs in workerd via vitest-pool-workers
op run --env-file=.env -- pnpm run dev # gateway worker locally
op run --env-file=.env -- pnpm run dev:all # gateway + workflows + responder + spend
pnpm run routes:build # regenerate the gateway's openapi.yaml/openapi.ts
pnpm run contracts:build # regenerate capnp modules after editing *.capnp
pnpm scaffold <name> # generate a complete new top-level application./deploy.sh # deploy core set + config:push + d1 migrate + register commands + gateway startpnpm run deploy (what deploy.sh calls) discovers every wrangler.jsonc
under apps/ and deploys in binding-safe order: auth first (every public
app binds it) → responder → workflows →
gateway (the gateway binds workflows' InteractionSession processor DO
cross-script) → spend. A discovered worker missing from DEPLOY_ORDER in
scripts/deploy.ts fails the deploy loudly. The webhooks
worker has one-time bootstrap steps and deploys individually:
pnpm run deploy:webhooks, or pnpm run deploy -- --only <names>.
Migrations: pnpm run d1:migrate:local / d1:migrate:remote. Change the
schema by adding a migration — never edit schema.sql (mirror only). Don't
set preview_database_id to the production DB; create ragbot-preview if
previews are ever needed.
Secrets go on the worker that needs them:
wrangler secret put NAME -c <that worker's wrangler.jsonc>.
-
D1:
wrangler d1 create ragbot, id into the gateway wrangler config, thenpnpm run d1:migrate:remote. -
Queues (before any deploy that references them):
ai-jobs,ai-jobs-dlq,ai-spend-jobs,ai-spend-jobs-dlq,discord-outbox,discord-outbox-dlq,webhook-jobs,webhook-jobs-dlq(wrangler queues create <name>). -
AI config KV:
wrangler kv namespace create AI_CONFIG, id intoapps/workflows/wrangler.jsonc, thenpnpm run config:push. -
Discord app: scopes
bot+applications.commands; permissions Send Messages, Create Public Threads, Send Messages in Threads, Use Slash Commands, Read Message History. Interactions endpoint =https://webhooks.jsmunro.me/{clientId}/interactions(the Discord application/client id); the webhooks worker verifies the Ed25519 signature, returns a fast type-5 ack, and kicks theInteractionSessionprocessor DO. Register commands:pnpm run register:commands. -
Auth worker (
apps/auth, the API Gateway): set the Cloudflare Access vars (CF_ACCESS_TEAM_DOMAIN;CF_ACCESS_AUDsecret per Access app) and, for the web client, a Discord OAuth app + secretsDISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET,BETTER_AUTH_SECRET,BETTER_AUTH_URL; apply the Better Auth migrations to theragbot-authD1 (wrangler d1 migrations apply ragbot-auth -c apps/auth/wrangler.jsonc --remote).GATEWAY_CONTROL_TOKEN(operator bearer token) andRAG_ADMIN_USER_IDSalso live here. -
Webhooks: put each provider's webhook secret on the auth worker (
wrangler secret put GITHUB_WEBHOOK_SECRET -c apps/auth/wrangler.jsonc), thenpnpm run deploy:webhooks. This shared ingress carries two concerns, both keyed by the caller's client/connector id:- Provider webhooks —
https://webhooks.jsmunro.me/{provider}/{connectorId}(e.g./github/github-app); the provider HMAC (verified by the auth worker) is the authentication. - Discord interaction callbacks —
https://webhooks.jsmunro.me/{clientId}/interactions; the Ed25519 signature is the authentication, verified against the client's public key in theDISCORD_INTERACTION_PUBLIC_KEYSvar (JSON{clientId: hexPubKey}). The worker binds workflows'INTERACTION_SESSIONDO cross-script to kick the processor.
The host is behind Cloudflare Access (service-token only), but the
/github/*and*/interactionspaths carry a Bypass=Everyone Access policy so providers and Discord can POST — the signature at the edge is the authentication there. All other paths require theragbot-webhooksservice token. - Provider webhooks —
- AI config is checked into
packages/discord/ai/ai-config(models, prompts, temperature, budgets per feature). The workflows worker reads each file from theAI_CONFIGKV first (keyed by basename) and falls back to the bundled copy, so an empty namespace or KV outage never bricks the bot. Values are memoized per isolate: publish a prompt change withpnpm run config:push(new isolates pick it up) or a redeploy. - AI usage limits (
packages/discord/domain/limits.ts): per-user burst (AI_BURST_LIMIT_PER_MINUTE, default 8/min) and a global daily budget (AI_GLOBAL_DAILY_BUDGET_USD, default 10.00) across all AI ingresses. Spend truth is AI Gateway log cost, reconciled by the spend worker. - Guild allowlist:
ALLOWED_GUILD_IDS(comma-separated snowflakes) on the gateway and workflows configs; fails closed when set, allows-with-warning when unset.
- Health:
createAppWorkerpublic workers serveGET /healthandGET /openapi.json(unauthenticated discovery) via the shared middleware. The gateway is the exception — it exposes only the operator control routes below. - Gateway websocket control:
POST /gateway/start,POST /gateway/stop(kill switch — stays down until the next start),GET /gateway/health; all requireAuthorization: Bearer $GATEWAY_CONTROL_TOKEN. No public discovery surface (no/openapi.json, no/.well-known/*). - Every DLQ has a consumer that logs
dead_letter_message(ids and envelope kinds only, never content) and acks, so dead letters surface in logs instead of accumulating. - Smoke tests after a deploy:
/rag+/ragboardin the guild;/askcreates a thread with an answer; a mention gets a reply; a GitHub redelivery to the webhook URL → 200 (dedupe), tampered body → 401.