Skip to main content
Glama
README.md
# agent-cms

Agent-first headless CMS. Runs as a Cloudflare Worker backed by D1 and R2 in your own account. No hosted service, no admin UI. Agents define schemas, manage content, and publish — via MCP.

When humans need to edit too, agent-cms does not ship a GUI. It ships the parts a GUI cannot be assembled without: **generated types and mutations** (typed RPC codegen) and **the one irreducible component** (a headless block editor whose document model is the content grammar). You bring the UI, components, auth, and router. See [`examples/admin/`](./examples/admin/) for a working admin built that way, and [`docs/adr/`](./docs/adr/) for why.

## What you get

- **Structured text with typed blocks** — a document tree where rich components (code blocks, media, custom types) are embedded inline. One GraphQL query returns the full tree with discriminated block unions. Map directly to React/Svelte/Vue components in a single server hop. Render with [`react-datocms`](https://github.com/datocms/react-datocms), `vue-datocms`, or `datocms-svelte` — the structured text format is [DAST](https://www.datocms.com/docs/structured-text/dast), an open standard.
- **Hybrid search** — FTS5 for keyword matching, Cloudflare Vectorize for semantic similarity, combined with reciprocal rank fusion. All on D1.
- **Draft/publish with preview** — records start as drafts. Publishing captures a version snapshot. Models can declare a `canonicalPathTemplate` so agents and editors get preview URLs for drafts. Short-lived preview tokens grant draft access to GraphQL without editor credentials. Schedule publish/unpublish at future datetimes. Full version history — restore to any snapshot, and the restore itself is reversible.
- **Geospatial filtering** — `lat_lon` field type with `near(latitude, longitude, radius)` queries in GraphQL.
- **Automatic reverse references** — link model A to model B, and B gets a query field for all records in A that reference it, with full filtering, ordering, and pagination.
- **Two MCP servers** — admin MCP (`/mcp`) for schema and content, editor MCP (`/mcp/editor`) scoped to content operations only. Create editor tokens with optional expiry.
- **Multi-locale with fallback chains** — per-field opt-in. Locale A falls back to B falls back to C. The GraphQL resolver walks the chain.
- **19 field types** — string, text, boolean, integer, float, date, date_time, slug (auto-generated), media (with focal point + blurhash), media_gallery, link, links, structured_text, rich_text, seo (title + description + image + twitter card), json, color (RGBA), lat_lon, video. All validated with Effect schemas.
- **Tree hierarchies and sortable collections** — parent-child nesting and explicit position ordering as first-class model properties.
- **Dynamic SQL builder** — the query engine builds SQL at runtime from the content schema. No ORM, no generated client. The content schema is decoupled from your application schema — run this on the same D1 database as your site.
- **Responsive images** — Cloudflare Image Resizing with focal points, blurhash for progressive loading, color palette extraction. R2 storage, no external service.
- **Bulk operations** — create up to 1000 records in a single call.
- **Schema portability** — export the full schema as JSON (no IDs, just api_keys), import it on a fresh instance.
- **Four interfaces** — REST API, GraphQL, MCP, and generated typed RPC, all derived from the content schema.
- **Headless editing primitives** — codegen emits a [result-rpc](https://result-rpc.com) contract + router whose handlers run the CMS's own Effect services in-process against your D1 binding (no REST hop), and `@agent-cms/editor-react` gives you a Tiptap-based structured-text editor that renders nothing and ships no CSS.
- **Effect-TS throughout** — typed errors, dependency injection via services and layers, no try/catch. The whole CMS is a single Worker.

## Quick start

Copy the prompt from [`PROMPT.md`](./PROMPT.md) into Claude Code. It assesses your project, asks how you want to integrate (standalone Worker, service binding, or mounted in an existing Worker), and wires everything up — including D1 database, wrangler config, and MCP server connection.

## Database setup

A fresh D1 database has no system tables, so the CMS cannot serve anything until its schema is
installed. **`POST /api/setup` installs it:**

```bash
curl -X POST https://your-worker.workers.dev/api/setup \
  -H "Authorization: Bearer $CMS_WRITE_KEY"
```

Skipping this is the usual cause of a `500` / `INTERNAL_SERVER_ERROR` from `/graphql` on a
brand-new deployment.

- It counts as a schema mutation, so it needs `CMS_WRITE_KEY` when one is configured.
- It is **idempotent and version-tracked** (`_cms_migrations`), so it is safe to call repeatedly.
- **Re-run it after upgrading the `agent-cms` package.** New versions ship additional migrations,
  and setup applies only the pending ones.

The migrations are embedded in the Worker bundle, so there is nothing to copy out of
`node_modules`. The equivalent `.sql` files also ship in the package under `migrations/` if you
prefer to drive them yourself with `wrangler d1 migrations apply` — both paths produce the same
schema, so pick one.

## Interfaces

### `/mcp` — Admin agent interface

MCP server with tools for schema management, content operations (CRUD, bulk insert, publish/unpublish, reorder), asset management, search, and schema import/export. Requires `writeKey`.

When the `LOADER` binding is configured, `/mcp` serves **Code Mode** instead — a single `code` tool where the LLM writes JavaScript that chains multiple tool calls in a V8 sandbox. This reduces round-trips: instead of one MCP call per tool, the LLM generates a script that calls all the tools it needs in one shot.

The sandbox exposes a `codemode` object with all admin tools as async methods:

```js
// The LLM writes this as the `code` argument:
async () => {
  const schema = await codemode.schema_info({ includeFieldDetails: true });
  const record = await codemode.create_record({
    modelApiKey: "blog_post",
    data: { title: "Hello World", slug: "hello-world" }
  });
  return { schema, record };
}
```

Tool names and argument shapes match the standard MCP tools exactly — `codemode.create_model(...)`, `codemode.create_field(...)`, etc. The `code` tool's description includes full type signatures for all available methods.

Code Mode requires [Dynamic Workers](https://developers.cloudflare.com/dynamic-workers/) (Workers Paid plan). Add the binding to `wrangler.jsonc`:

```jsonc
"worker_loaders": [{ "binding": "LOADER" }]
```

Without the `LOADER` binding, `/mcp` serves the standard multi-tool MCP server.

### `/mcp/editor` — Editorial agent interface

Reduced MCP server for content-authoring agents. Accepts either an editor token or `writeKey`. Exposes schema introspection, record CRUD, drafts, publish/unpublish, version restore, assets, site settings, and search. Does not expose schema mutation, token management, or admin operations.

When `LOADER` is configured, `/mcp/editor` also serves Code Mode with the editor tool subset.

### `/graphql` — Content delivery

Read-only GraphQL API. Supports filtering, ordering, pagination, locale fallback, and draft previews via `X-Include-Drafts`.

```graphql
{
  allPosts(
    filter: { _status: { eq: "published" } }
    orderBy: [_createdAt_DESC]
    first: 10
  ) {
    id
    title
    slug
    coverImage {
      url
      width
      height
      alt
    }
    body {
      value
      blocks {
        ... on CodeBlockRecord {
          id
          code
          language
        }
      }
    }
  }
}
```

#### Naming conventions

Model `api_key` values (snake_case) map to camelCase/PascalCase GraphQL names:

| api_key | GraphQL type | Single query | List query | Meta query |
|---------|-------------|-------------|------------|------------|
| `blog_post` | `BlogPostRecord` | `blogPost` | `allBlogPosts` | `_allBlogPostsMeta` |
| `category` | `CategoryRecord` | `category` | `allCategories` | `_allCategoriesMeta` |

Block types also get a `Record` suffix: `code_block` → `CodeBlockRecord`.

Field `api_key` values become camelCase in queries: `cover_image` → `coverImage`, `published_at` → `publishedAt`.

#### Performance model

GraphQL nesting is not compiled into one giant SQL join. The server fetches root records, batches linked records and StructuredText work into set-oriented SQL, then assembles the nested shape in memory. See [`PERFORMANCE.md`](./PERFORMANCE.md).

#### MCP resources and prompts

Agents connecting via MCP get two resources:
- **`agent-cms://guide`** — workflow order, naming conventions, field value formats
- **`agent-cms://schema`** — current schema as JSON

Two prompts for common workflows:
- **`setup-content-model`** — design and create content models from a description
- **`generate-graphql-queries`** — generate typed GraphQL queries for a model

### `/api` — REST

JSON REST API for programmatic access. Models, fields, records, assets, locales, publish/unpublish, scheduling, bulk operations, schema import/export.

### `/api/search` — Search

FTS5 keyword search with BM25 ranking and snippets, scoped to all models or a single model. When `AI` + `VECTORIZE` bindings are configured:

- **`keyword`** — FTS5. Phrases (`"exact match"`), prefix (`word*`), boolean (`AND`/`OR`).
- **`semantic`** — Vectorize cosine similarity.
- **`hybrid`** (default) — Reciprocal rank fusion of keyword + semantic results.

### Generated typed RPC — editing surface

For editing UIs, MCP is the wrong shape and GraphQL is read-only. `@agent-cms/codegen` reads a
schema export and emits [result-rpc](https://result-rpc.com) **fragments** that merge into your own
app's contract and router:

```bash
agent-cms-codegen --schema https://cms.example.com --out-dir src/cms
```

- `contract.ts` — browser-safe. Per-model wire codecs and TS types (`Post`, `CreatePost`,
  `UpdatePost`), block payload unions, presentation descriptors, and
  `cmsContract(app, { mutationErrors })`.
- `procedures.ts` — server-only. `cmsProcedures(app, contract, deps)`, handlers running agent-cms's
  Effect services **in-process** against your D1/R2 bindings.

You own the `rpc.context`, so CMS procedures sit beside your own under one client, one cache, and
one failure algebra. Auth is yours: agent-cms declares no auth error tag, and your middleware's
error is threaded through the CMS mutations via `mutationErrors`. Reads, writes, publish, versions,
search, backlinks, bulk operations and the asset library are all exposed. See
[`packages/codegen/README.md`](./packages/codegen/README.md).

## Packages

| Package | What it is |
|---------|-----------|
| [`agent-cms`](./src/) | The Worker. Also exports `agent-cms/lib` — the services (record, publish, version, schedule, asset) as an embeddable library for in-process hosts. |
| [`@agent-cms/dast`](./packages/dast/) | DAST node types and grammar constants. Zero dependencies, the single source of truth shared by the CMS, the editor, and generated contracts — so an editor document is assignable to a generated write input with no adapter. |
| [`@agent-cms/codegen`](./packages/codegen/) | The typed RPC emitter above, plus `@agent-cms/codegen/assets` (`assetUrl`, `assetSrcSet` for Cloudflare Image Resizing). |

[result-rpc](https://result-rpc.com) is a **sibling library**, not a third-party pick — a shared
foundation developed alongside agent-cms so that insourced systems compose into one app. Published
on npm (`result-rpc@0.5.0`); this workspace links it from a local checkout via
`link:../../../result-rpc` so CMS and RPC land changes land together.
| [`@agent-cms/editor-react`](./packages/editor-react/) | Headless structured-text editor on Tiptap 3. `/bridge` is the engine layer: Tiptap extensions whose `NodeSpec.content` **is** the DAST grammar, plus a lossless DAST ↔ ProseMirror codec. `useDastEditor` adds typed commands and reactive state for your toolbar. Renders nothing, ships no CSS. |

## Draft preview

Records on draft-enabled models start as drafts. The CMS stores both the draft state (real columns) and the published snapshot. GraphQL serves published content by default. Draft preview lets agents and editors see unpublished content on the real site before publishing.

### How it works

1. **Set a URL template on your model** — `canonicalPathTemplate: "/posts/{slug}"`. The CMS resolves `{slug}` from the record and includes `_previewPath` in tool/REST responses.

2. **Configure the site URL** — pass `siteUrl` to `createCMSHandler`. The CMS uses this to generate fully assembled preview links.

3. **Agent or editor requests a preview** — the `get_preview_url` MCP tool returns a ready-to-share link with a short-lived preview token baked in. One tool call, no assembly required.

4. **User clicks the preview link** — the site's enable route validates the token against the CMS, sets a cookie, and redirects to the content page. The site then fetches draft content from GraphQL.

5. **Disable draft mode** — clear the cookie via `/api/draft-mode/disable?redirect=/`.

### MCP workflow

The agent never assembles preview URLs manually. One tool call does everything:

```
Agent: create_record({ modelApiKey: "post", data: { title: "Draft Post", ... } })
CMS:   { id: "abc", _status: "draft", _previewPath: "/posts/draft-post" }

Agent: get_preview_url({ recordId: "abc", modelApiKey: "post" })
CMS:   { url: "https://mysite.com/api/draft-mode/enable?token=pvt_...&redirect=/posts/draft-post",
         previewPath: "/posts/draft-post", expiresAt: "2026-03-24T17:00:00Z" }

Agent: "Here's your draft preview: https://mysite.com/api/draft-mode/enable?token=pvt_...&redirect=/posts/draft-post"
```

If the agent has browser access, it can follow the link to visually verify content and iterate before publishing.

### Site integration

Your site needs an enable route that validates the preview token against the CMS, sets an `HttpOnly` cookie (`__agentcms_preview`) with the token, and redirects to the content page. The cookie's `Max-Age` should match the token's remaining TTL. See [`examples/nextjs/`](./examples/nextjs/) and [`examples/blog/`](./examples/blog/) for complete implementations.

In your GraphQL client, forward the cookie as a header when present:

```ts
const previewToken = getCookie("__agentcms_preview");
const headers: Record<string, string> = {};
if (previewToken) {
  headers["X-Preview-Token"] = previewToken;
  headers["Cache-Control"] = "no-store"; // bypass CDN for draft content
}
```

GraphQL responses in preview mode include `Cache-Control: private, no-store`. Render a preview banner in your site to indicate draft mode — see the examples for Astro and Next.js implementations.

### Service bindings

When your site and CMS are in the same Cloudflare account, skip cookies entirely. Pass `X-Preview-Token` directly on the service binding call:

```ts
const res = await env.CMS.fetch("https://internal/graphql", {
  headers: {
    "X-Preview-Token": previewToken, // from your site's own session/cookie
  },
  body: JSON.stringify({ query }),
});
```

No CORS, no cross-origin cookies, zero latency. This is the recommended path for Cloudflare deployments.

### Framework notes

**Astro / SvelteKit / generic Workers** — build an enable route that validates the token and sets the cookie. Read the cookie in middleware and forward as `X-Preview-Token`.

**Next.js** — your enable route must also call `draftMode().enable()` to integrate with Next.js ISR/caching. Set `SameSite=None; Secure` on the cookie for iframe compatibility. See the DatoCMS Next.js pattern — the same approach applies.

### URL templates

Templates use `{field_name}` placeholders resolved from the record:

| Template | Record | Result |
|----------|--------|--------|
| `/posts/{slug}` | `{ slug: "hello-world" }` | `/posts/hello-world` |
| `/docs/{category}/{slug}` | `{ category: "guides", slug: "setup" }` | `/docs/guides/setup` |
| `/{slug}` | `{ slug: "about" }` | `/about` |

Templates are paths only — no origin, no locale prefix. Locale URL strategies (prefix, subdomain, root folding) are handled by your site's routing, not the CMS. The enable route accepts an optional `locale` param for your middleware to use.

### Preview tokens

- Created internally by `get_preview_url` or via `POST /api/preview-tokens`
- Default expiry: 24 hours (editorial workflows are async)
- Stored as SHA-256 hashes in D1 (a database breach doesn't leak usable tokens)
- Grant read-only access to all draft content via GraphQL
- `X-Preview-Token` header takes precedence over `__agentcms_preview` cookie if both are present

## Editor tokens

Editor tokens are the credential for non-admin editing flows. Create them via `POST /api/tokens` or the `editor_tokens` MCP tool (with `action: "create"`). The raw token is shown once; the server stores a hash.

Editor tokens can access `/mcp/editor`, REST content/asset operations, and draft GraphQL previews. They cannot mutate schema, manage tokens, or run admin operations.

For editor onboarding with OAuth, the package exports `createCmsAdminClient` and `createEditorMcpProxy` to stand up an app-land MCP gateway. See [`examples/editor-mcp/`](./examples/editor-mcp/).

## Scheduling

Schedule publish/unpublish at future datetimes via REST or MCP. To execute schedules automatically, add a cron trigger:

```ts
import { createCMSHandler } from "agent-cms";

let cachedHandler: ReturnType<typeof createCMSHandler> | null = null;

function getHandler(env: Env) {
  if (!cachedHandler) {
    cachedHandler = createCMSHandler({
      bindings: { db: env.DB, assets: env.ASSETS, writeKey: env.CMS_WRITE_KEY },
    });
  }
  return cachedHandler;
}

export default {
  fetch(request: Request, env: Env) {
    return getHandler(env).fetch(request);
  },
  scheduled(_controller: ScheduledController, env: Env) {
    return getHandler(env).runScheduledTransitions();
  },
};
```

```json
{ "triggers": { "crons": ["* * * * *"] } }
```

Without a cron trigger, schedules are stored and queryable but do not execute.

## Lifecycle hooks

React to content events with hooks passed to `createCMSHandler`:

```ts
createCMSHandler({
  bindings: { db: env.DB, assets: env.ASSETS, writeKey: env.CMS_WRITE_KEY },
  hooks: {
    onPublish: ({ modelApiKey, recordId }) => fetch(env.DEPLOY_HOOK_URL, { method: "POST" }),
    onRecordCreate: ({ modelApiKey, recordId }) => { /* notify, sync, etc. */ },
  },
});
```

Available: `onRecordCreate`, `onRecordUpdate`, `onRecordDelete`, `onPublish`, `onUnpublish`. All receive `{ modelApiKey, recordId }`. Fire-and-forget.

## Bindings

Only `DB` is required. Everything else is optional and degrades gracefully.

| Binding | Type | What it enables |
|---------|------|-----------------|
| `DB` | D1 | **Required.** Content storage, schema, FTS5 search. |
| `ASSETS` | R2 | Asset file storage and serving via `/assets/`. |
| `AI` | Workers AI | Embedding generation for semantic search. |
| `VECTORIZE` | Vectorize | Semantic vector search. Requires `AI`. |
| `CMS_WRITE_KEY` | Secret | Auth for writes, MCP, and publish. Without it, writes are open. |
| `ASSET_BASE_URL` | Variable | Public URL prefix for non-image asset files served from R2. |
| `SITE_URL` | Variable | Your site's public URL (e.g. `https://mysite.com`). Required for `get_preview_url` to generate fully assembled preview links. |
| `LOADER` | Worker Loader | Code Mode — collapses MCP tools into a single `code` tool. Requires [Dynamic Workers](https://developers.cloudflare.com/dynamic-workers/) (Workers Paid plan). |
| `IMAGES` | Images binding | **Hosted image assets** — keyless direct uploads (`create_asset_upload_url`, `POST /api/assets/upload-url`) and server-side image import. Requires a Cloudflare Images plan. |

### Keyless direct image uploads

Images are stored in **Cloudflare Images**, managed through the `images` binding on the Worker —
no S3-compatible signing credentials are involved and image bytes never pass through the CMS
Worker. `create_asset_upload_url` / `POST /api/assets/upload-url` mint a one-time upload URL via
`IMAGES.hosted.createDirectUpload()`, the client uploads straight to Cloudflare
(multipart form POST, field `file`), then registers the asset with the returned
`imageId` + `deliveryBase`. Non-image files (PDFs, videos) still go to R2 via
`import_asset_from_url` or the `PUT /api/assets/:id/file` route.

Requires an **Images binding**:

```jsonc
{
  "images": { "binding": "IMAGES" }
}
```

Pass it through `createCMSHandler`:

```ts
createCMSHandler({
  bindings: {
    db: env.DB,
    assets: env.ASSETS,
    images: env.IMAGES,
    writeKey: env.CMS_WRITE_KEY,
  },
});
```

```jsonc
{
  "d1_databases": [{ "binding": "DB", "database_name": "my-cms-db", "database_id": "..." }],
  "r2_buckets": [{ "binding": "ASSETS", "bucket_name": "my-cms-assets" }],
  "images": { "binding": "IMAGES" },
  "vectorize": [{ "binding": "VECTORIZE", "index_name": "my-cms-content" }],
  "ai": { "binding": "AI" },
  "worker_loaders": [{ "binding": "LOADER" }],
  "vars": { "ASSET_BASE_URL": "https://cms.example.com" }
}
```

To create the Vectorize index: `npx wrangler vectorize create my-cms-content --dimensions=384 --metric=cosine`

## Assets

Two storage kinds behind one metadata model (D1 `assets` rows):

- **Images** (`image/*`) live in Cloudflare Images and deliver from
  `https://imagedelivery.net/...` variant URLs. Direct uploads are keyless:
  `create_asset_upload_url` (MCP) / `POST /api/assets/upload-url` mint a
  one-time URL via the Images binding and the client uploads straight to
  Cloudflare. `import_asset_from_url` ingests server-side into Images.
- **Other files** (PDFs, videos) live in R2 and serve from `/assets/:id/:filename`
  (or your `ASSET_BASE_URL`). Server-side import (`import_asset_from_url`) or
  worker-mediated `PUT /api/assets/:id/file`, then register metadata.
- **Server**: upload to R2, then `POST /api/assets`

Focal points, blurhash, and color palette are stored per-asset. Cloudflare Image Resizing generates responsive variants at the edge.

> **`responsiveImage` returns `null` when an asset has no `width`/`height`.** It cannot compute
> srcSet breakpoints or an aspect ratio without them, so images uploaded without dimensions
> silently render nothing rather than erroring. If a `responsiveImage` field comes back empty,
> check the asset row first. Dimensions can be backfilled with
> `PATCH /api/assets/:id` (`width`, `height`).

### Canonical URLs

Every asset a read returns carries an absolute `url`, resolved in this order:

1. **`ASSET_BASE_URL` is set** → `<ASSET_BASE_URL>/<r2_key>`. The base must be a Cloudflare custom
   domain serving the bucket (that is also what Image Resizing transforms require).
2. **No base URL, but the origin is known** (any HTTP request to the CMS Worker, or a library host
   passing `assets.originUrl`) → `<origin>/assets/<id>/<filename>`, the route above.
3. **Neither** → the same-origin relative path `/assets/<id>/<filename>`.

`media` and `media_gallery` values in record reads are enriched the same way — the stored
reference merged with its asset row (`upload_id`, `url`, `filename`, `mime_type`, `size`, `width`,
`height`, `alt`, `title`, `focal_point`, `custom_data`, `blurhash`) — and `seo` gains `image_url`
beside its `image` id. Writes are unchanged (an asset id or an upload descriptor); the read-only
keys are stripped on write, so read-modify-write round-trips exactly. Resolution costs one batched
query per record set, block payloads included.

Clients compose transforms with `assetUrl(asset, { width, height, fit, format, quality })` from
`@agent-cms/codegen/assets`, which builds Cloudflare Image Resizing URLs
(`<origin>/cdn-cgi/image/<options>/<path>`).

## Stack

- **Runtime**: Cloudflare Workers
- **Database**: D1 (managed SQLite)
- **Assets**: R2 + Cloudflare Image Resizing
- **Search**: SQLite FTS5 + Cloudflare Vectorize
- **Application**: [Effect](https://effect.website)
- **GraphQL**: [graphql-yoga](https://the-guild.dev/graphql/yoga-server) with generated SDL
- **Testing**: [Vitest](https://vitest.dev) (`pnpm test`)

## Examples

- [`examples/blog/`](./examples/blog/) — CMS Worker + Astro SSR site with typed GraphQL (gql.tada), structured text rendering, responsive images, service bindings, **draft preview mode**
- [`examples/nextjs/`](./examples/nextjs/) — Next.js App Router with `draftMode()` integration, multi-root GraphQL queries, preview bar component
- [`examples/editor-mcp/`](./examples/editor-mcp/) — editor onboarding: app-land OAuth gateway, scoped editor tokens, separate MCP URLs for developers and editors
- [`examples/admin/`](./examples/admin/) — **the proof**: a working content admin built from only the generated RPC client, the headless editor, and the app's own components. Record list, editing form with live validation, structured-text editing, media library. Its [`FRICTION.md`](./examples/admin/FRICTION.md) records every rough edge found while building it.
- [`examples/editor/`](./examples/editor/) — minimal `useDastEditor` harness for the block editor on its own

## Design decisions

Architecture decisions are recorded as ADRs in [`docs/adr/`](./docs/adr/): DatoCMS parity policy,
Tiptap as the block-editor engine, the DAST grammar, RPC fragments running in-process, the failure
algebra, and why there is no admin UI.

## License

MIT