A minimal, API-first headless CMS for Cloudflare Workers and D1. Define collections with standard JSON Schema, create content, and query it over HTTP. Built for AI agents and services.
For agent-specific conventions, see AGENTS.md. To contribute, see CONTRIBUTING.md. For runnable client examples, see examples/.
The worker needs the following bindings to run:
DB- Cloudflare D1 - SQLite database for collections, content, schema versions, and media metadata.MEDIA_BUCKET- Cloudflare R2 - Object storage for uploaded media files.OAUTH_KV- Cloudflare KV - Token and grant storage for the OAuth provider. Separate from D1.JWT_SECRET- Secret - The secret used to sign and verify API keys.DOCS_SECRET- Secret - Password for the/docsinteractive API reference (HTTP Basic Auth).MCP_ADMIN_SECRET- Secret - Single shared passphrase for the OAuth consent screen login. Only the operator needs this.MEDIA_PUBLIC_URL- Var - Public URL for the R2 bucket (e.g.https://pub-abc123.r2.dev). Optional. Enables direct access to uploaded files without going through the worker.RATE_LIMITER- Rate limiting - Already configured inwrangler.jsonc(100 requests per 10 seconds). No setup needed.
The fastest way to deploy is through Cloudflare's GitHub integration.
This clones the repo into your GitHub account and deploys the worker. You can configure the project name, D1 binding, and secrets during setup. Keep note of JWT_SECRET; you will need it to generate API keys.
Migrations are applied automatically as part of the deploy step.
Note: The cloned repo does not include the
.github/workflows/update.ymlfile. To enable the GitHub Actions update workflow, run the Manual update steps once.
You need the following installed:
- pnpm - https://pnpm.io/installation
- wrangler - https://developers.cloudflare.com/workers/wrangler/install-and-update/
- node - https://nodejs.org/en/download
Steps:
- Clone the repository
git clone https://github.com/butttons/pouch
cd pouch- Login with wrangler
npx wrangler login- Create a D1 database
npx wrangler d1 create pouch-cmsCopy the database ID from the output and update wrangler.jsonc:
"d1_databases": [
{
"binding": "DB",
"database_name": "pouch-cms",
"database_id": "[DATABASE_ID]",
"migrations_dir": "src/lib/db/migrations"
}
]- Create an R2 bucket
npx wrangler r2 bucket create pouch-mediaThe bucket name must match the bucket_name in wrangler.jsonc:
"r2_buckets": [
{
"binding": "MEDIA_BUCKET",
"bucket_name": "pouch-media"
}
]- Deploy the worker
pnpm run deploypnpm run deploy runs db:migrate:prod before deploying, so the D1 migrations are applied automatically.
- Set the
JWT_SECRETsecret
npx wrangler secret put JWT_SECRETGenerate a strong secret and keep it safe. You will need it to create API keys.
- Set the
MCP_ADMIN_SECRETsecret (optional — only needed for OAuth MCP)
npx wrangler secret put MCP_ADMIN_SECRETThis is the single shared passphrase used to log in to the OAuth consent screen at /authorize.
- Set the
DOCS_SECRETsecret
npx wrangler secret put DOCS_SECRETThis is the password for the /docs interactive API reference (HTTP Basic Auth, username pouch).
- Install dependencies
pnpm install- Create
.dev.varsin the project root
JWT_SECRET='your-local-dev-secret-min-32-chars-long'
DOCS_SECRET='your-local-docs-password'
MCP_ADMIN_SECRET='your-local-dev-passphrase'- Generate an admin key (optional)
If you need an admin token for local scripts or remote management, post your JWT_SECRET to /auth/keys and store the token in .env.local (already ignored by git):
curl -X POST http://localhost:3200/auth/keys \
-H "Content-Type: application/json" \
-d '{"secret": "[JWT_SECRET]", "name": "local-admin", "scopes": ["collection:read","collection:write","content:read","content:write","media:read","media:write","audit:read"]}' \
| jq -r '.token' > .env.local- Run the local dev server
pnpm devThe worker runs at http://localhost:3200.
- Run tests
pnpm testAll REST API routes (/collections, /media, /audit-logs, /openapi.json) require a Bearer token — /auth/keys is gated by the worker JWT_SECRET instead, and the OAuth endpoints (/register, /authorize, /token) plus /docs have their own auth. Generate a key by posting your JWT_SECRET:
curl -X POST http://localhost:3200/auth/keys \
-H "Content-Type: application/json" \
-d '{
"secret": "[JWT_SECRET]",
"name": "my-agent",
"scopes": ["collection:read", "content:read", "content:write"],
"collections": ["faqs", "pages"]
}'Response:
{
"token": "JWT_STRING",
"jti": "key_...",
"name": "my-agent",
"scopes": ["collection:read", "content:read", "content:write"],
"collections": ["faqs", "pages"],
"exp": 1234567890
}name and scopes are required — the name identifies the key holder in audit logs, and every key must declare its scopes explicitly. Use expiresInSeconds to override the default 180-day expiry.
Scopes mirror the endpoint groups:
| Scope | Endpoints |
|---|---|
collection:read |
GET /collections* |
collection:write |
POST/PATCH/DELETE /collections* |
content:read |
GET /collections/:slug/content* (also requires collection:read) |
content:write |
mutations under /collections/:slug/content* (also requires collection:read) |
media:read |
GET /media* |
media:write |
POST/DELETE /media* |
audit:read |
GET /audit-logs* |
GET /openapi.json requires collection:read.
Pass collections (an array of slugs) when creating a key to confine it to those collections. Every route under a collection — content, schema, delete — responds 403 for any slug outside the list, and GET /collections only returns the permitted collections. Media and audit-log routes are not collection-scoped and are unaffected. Omit collections for a key that works across all collections.
Every request passes through the RATE_LIMITER binding (100 requests per 10 seconds, configured in wrangler.jsonc). Requests with a valid API key are limited per key (jti); unauthenticated requests are limited per client IP. Exceeding the limit returns 429 with a JSON error body (RATE_LIMITED).
pouch uses D1 global read replication through the Sessions API when a bookmark is provided.
- Send
x-d1-bookmark: first-unconstrainedto read from any replica. - Send
x-d1-bookmark: first-primaryto read the latest data on the first read. - Pass the bookmark from a previous response in the
x-d1-bookmarkheader to keep sequential consistency across requests. - If the header is missing, the request uses the primary D1 database directly and no session is created.
- The response includes an updated
x-d1-bookmarkheader only when a bookmark was provided on the request.
Example:
curl -H "Authorization: Bearer [TOKEN]" \
-H "x-d1-bookmark: first-unconstrained" \
https://pouch-cms.[account].workers.dev/collections/faqs/contentThe response will include x-d1-bookmark, which you can send on the next request to keep sequential consistency.
Note: served_by_region and served_by_primary are only returned by remote D1. They are undefined in local development.
pouch exposes its REST API as an MCP server at /mcp. Any MCP client (Cursor, Claude Code, Claude Desktop, etc.) can connect and use the API as tools without extra configuration.
Requirements:
- The worker needs the
nodejs_alscompatibility flag, which is already set inwrangler.jsonc.
Connect a client to:
https://pouch-cms.[account].workers.dev/mcp
For local development, use http://localhost:3200/mcp.
The MCP server assembles the OpenAPI document in-process on every request (no HTTP call, no caching) and registers one tool per operation, so the tool list always reflects the collections that exist right now. Auth is passed through, so each tool call needs a valid Authorization: Bearer [TOKEN] header. The available tools depend on the token's scopes — read tools need the matching :read scope, write tools the matching :write scope (see the scope table above). A key restricted via collections only sees tools for its permitted collections; collection-level tools like list_collections stay visible and filter their results.
/auth/keys and other sensitive paths are excluded from the tool list.
Some MCP clients (the Claude chat app, ChatGPT connectors) only support OAuth and have no custom-headers option. pouch supports OAuth 2.1 authorization for the /mcp route specifically, while the REST API continues to use the existing bearer-token auth.
Clients self-register via RFC 7591 Dynamic Client Registration at POST /register — there is no operator-managed client registry. Registered clients are stored in OAUTH_KV and expire after 90 days; MCP clients re-register on demand. Clients are public (PKCE-only) — no client secrets are issued for token_endpoint_auth_method: "none" registrations.
Whichever client you connect, the flow ends at the pouch consent screen: enter the operator passphrase (MCP_ADMIN_SECRET), review the scope checkboxes, and approve. On successful grant, an auth.oauth.grant audit log entry is written with the client name and granted scopes. Grants and tokens are stored in OAUTH_KV, separate from D1.
- Open Settings → Connectors → Add custom connector.
- MCP server URL:
https://pouch-cms.[account].workers.dev/mcp - Leave the OAuth client fields blank — Claude registers itself via DCR on first connect.
- Click Connect and complete the pouch consent screen.
- In ChatGPT, open Settings → Apps & Connectors → Advanced Settings and enable Developer Mode.
- Click Create → New App. Set MCP server URL to
https://pouch-cms.[account].workers.dev/mcpand Authentication to OAuth. - Leave client registration on the default (automatic) — ChatGPT registers itself via DCR using its per-connector callback URI.
- Click Create, then Connect, and complete the pouch consent screen.
Note: Claude Code, Cursor, and other clients that support custom headers should keep using the existing bearer-token approach (e.g. .mcp.json with Authorization: Bearer [TOKEN]). OAuth is specifically for clients that require it and have no alternative.
The OAuth provider automatically serves RFC 8414 and RFC 9728 discovery metadata at:
/.well-known/oauth-authorization-server/.well-known/oauth-protected-resource
These are relative to the /mcp route (e.g. https://pouch-cms.[account].workers.dev/.well-known/oauth-authorization-server).
pouch serves interactive API documentation at /docs using Scalar. The page is protected by HTTP Basic Auth with username pouch and password DOCS_SECRET:
open https://pouch:[DOCS_SECRET]@pouch-cms.[account].workers.dev/docsThe page is generated from the same OpenAPI spec as /openapi.json, so it reflects the current collections, scopes, error responses, and examples. For local development use http://localhost:3200/docs.
pouch serves a live OpenAPI 3.1 spec at /openapi.json. Because the assembler expands /collections/{slug}/content into concrete paths per collection, a generated client uses those concrete paths directly and types query filters from each collection's JSON Schema. Runnable versions of everything below live in examples/.
Install the tooling in your consumer project:
npm install openapi-fetch
npm install -D openapi-typescriptFetch the spec with your token, then generate types from it:
curl -sf https://pouch-cms.[account].workers.dev/openapi.json \
-H "Authorization: Bearer [TOKEN]" -o openapi.json
npx openapi-typescript openapi.json -o ./src/generated/pouch.ts
rm openapi.jsonThen create a client:
import createClient from "openapi-fetch";
import type { paths } from "./generated/pouch";
const client = createClient<paths>({
baseUrl: "https://pouch-cms.[account].workers.dev",
headers: { Authorization: `Bearer ${TOKEN}` },
});
const { data, error } = await client.GET("/collections/best_deals/content", {
params: { query: { price: 58036 } },
});If you are calling pouch from another Cloudflare Worker, do not go over the public network. Use a service binding instead.
Add the binding to your worker's wrangler.jsonc, pointing at your pouch worker's name:
"services": [
{
"binding": "POUCH",
"service": "pouch-cms"
}
]Then pass the binding's fetch to openapi-fetch. The binding ignores the hostname, so baseUrl can be anything:
import createClient from "openapi-fetch";
import type { paths } from "./generated/pouch";
const pouch = createClient<paths>({
baseUrl: "https://pouch",
headers: { Authorization: `Bearer ${env.POUCH_TOKEN}` },
fetch: (input) => env.POUCH.fetch(input),
});
const { data, error } = await pouch.GET("/collections/faqs/content", {
params: { query: { type: "faq", limit: 5 } },
});Run npx wrangler types after adding the binding so the Env type includes it. Full runnable worker: examples/consumer-worker.
For read-heavy consumers, cache pouch GET responses inside your worker with the Cache API and invalidate on writes:
- Cache only successful GETs, keyed by URL, with a long
s-maxageas a backstop TTL. - Stamp each entry with
Cache-Tag: the collection (col-articles), the content id for item responses, and the target collection of everyresolve=relation (resolved data is embedded, so those entries must die when the related collection changes). - After any mutation, purge the affected
col-*tag via the zone purge API (POST /zones/{zone_id}/purge_cache). This requires the worker to run on a custom domain and an API token with cache-purge permission.
Full runnable worker with a mutation endpoint that demonstrates purging: examples/caching-worker.
Fetch content at build time with the typed client; environment variables come from .env via import.meta.env:
// src/lib/pouch.ts
import createClient from "openapi-fetch";
import type { paths } from "../generated/pouch";
export const pouch = createClient<paths>({
baseUrl: import.meta.env.POUCH_URL,
headers: { Authorization: `Bearer ${import.meta.env.POUCH_TOKEN}` },
});---
// src/pages/index.astro
import { pouch } from "../lib/pouch";
const { data, error } = await pouch.GET("/collections/articles/content", {
params: { query: { resolve: "author" } },
});
if (error) throw new Error(`pouch request failed: ${error.code}`);
// List endpoints return content in every status; keep published only.
const articles = data.data.filter((a) => a.status === "published");
---Full runnable site: examples/astro-blog.
Update your worker when a new version is released. Your wrangler.jsonc is never overwritten; all D1 bindings, secrets, and other settings are preserved.
Note: This will discard any local changes except
wrangler.jsonc. Back up any custom modifications before updating.
- Go to your worker repo on GitHub
- Navigate to Actions > Update Worker
- Click Run workflow
- Optionally enter a specific version tag (e.g.
v0.0.2), or leave empty for the latest release - The workflow downloads the latest worker code, preserves your
wrangler.jsonc, and commits the update
The Deploy button creates a private repo from a snapshot, not a Git fork, so the first merge from upstream requires --allow-unrelated-histories.
- Add the upstream remote (first time only)
git remote add upstream https://github.com/butttons/pouch.git- Backup config, fetch and merge upstream
cp wrangler.jsonc wrangler.jsonc.bak
git fetch upstream
# First update only: histories are unrelated, so allow the merge.
git merge -X theirs upstream/main --allow-unrelated-histories -m "Update from upstream"
# Subsequent updates can use:
# git merge -X theirs upstream/main -m "Update from upstream"- Restore your config
mv wrangler.jsonc.bak wrangler.jsonc- Deploy
pnpm run deploypnpm run deploy runs db:migrate:prod before deploying, so D1 migrations are applied automatically.
Note: The
.github/workflows/update.ymlfile is added after the first manual update. Once it is present, you can use the GitHub Actions workflow for future updates instead of merging locally.