@se-studio/contentful-cms
v1.0.15
Published
CLI tool for AI agents to read and edit Contentful draft content without publish permissions
Maintainers
Readme
@se-studio/contentful-cms
A CLI tool for AI agents to read and edit Contentful draft content across all SE Studio apps. Provides a cms-edit binary with a snapshot → ref → edit → save workflow similar to agent-browser.
Key constraint: This tool has NO ability to publish, unpublish, archive, or delete published entries. All writes create drafts that a human must review and publish in Contentful.
Installation
npm install -g @se-studio/contentful-cmsOr from the monorepo root:
pnpm install
pnpm buildContentful Role Setup (Required)
Before using this tool, create a dedicated AI Editor role in your Contentful space with restricted permissions. This provides defence-in-depth safety on top of the CLI's own restrictions.
Steps
- Go to Settings → Roles & Permissions in your Contentful web app
- Click Add Role and name it
AI Editor - Under Content permissions, set:
- Entries: ✅ Read, ✅ Create, ✅ Edit — ❌ Publish, ❌ Unpublish, ❌ Archive, ❌ Delete
- Assets: ✅ Read — ❌ everything else
- Under Space and Settings — leave all ❌
- Save the role
- Go to Settings → API Keys → Content management tokens
- Create a Personal Access Token scoped to this role (or invite a service account user with this role and generate their token)
- Store the token as an environment variable (e.g.
CMS_EDIT_TOKEN)
Configuration
Create .contentful-cms.json in your project root (copy from .contentful-cms.example.json):
{
"defaultSpace": "om1",
"spaces": {
"om1": {
"spaceId": "your-space-id",
"environment": "master",
"managementToken": "${CMS_EDIT_TOKEN_OM1}",
"defaultLocale": "en-US",
"devBaseUrl": "http://localhost:3013"
},
"brightline": {
"spaceId": "another-space-id",
"environment": "master",
"managementToken": "${CMS_EDIT_TOKEN_BRIGHTLINE}",
"defaultLocale": "en-US",
"devBaseUrl": "${DEV_BASE_URL}",
"requestParams": {
"x-vercel-protection-bypass": "${VERCEL_BYPASS_TOKEN}"
}
}
}
}Token values use ${ENV_VAR} syntax and are resolved from your .env.local at runtime.
Deployment Protection Bypass
When your dev site is deployed behind Vercel deployment protection (or similar), cms-edit
commands that navigate to devBaseUrl (e.g. screenshot) need to bypass the protection.
Via query parameter (Vercel protection bypass):
"requestParams": {
"x-vercel-protection-bypass": "${VERCEL_BYPASS_TOKEN}"
}The token is appended as a query parameter to every URL built from devBaseUrl.
Via HTTP header (custom auth headers):
"requestHeaders": {
"x-custom-header": "${CUSTOM_HEADER_VALUE}"
}The headers are injected into the agent-browser open call via --headers.
Both requestParams and requestHeaders values support ${ENV_VAR} token syntax.
Set the token values in .env.local — they are never committed to version control.
Workflow
# 1. Open a page by slug — fetches the entry tree and starts a session
cms-edit open /pricing
# 2. View the content tree with @refs
cms-edit snapshot
# 3. Read a specific component's fields
cms-edit read @c2
# 4. Read a specific rich text field
cms-edit read @c2 body
# 5. Edit a scalar field
cms-edit set @c2 heading "New heading text"
cms-edit set @c2 showHeading true
# 5b. Set an Object/JSON field from a file
cms-edit set @c2 data --file chart-data.json
# 5c. Set an entry link field (e.g. page template)
cms-edit set @c0 template 3I0HxGKbUd173wIpFCsbVr --link
# 6. Edit a rich text field (Markdown input)
cms-edit rtf @c2 body "## Why it matters\n\nOur platform helps teams **move faster** with [confidence](https://example.com)."
# 7. Review changes before saving
cms-edit diff
# 8. Save all changes as Contentful drafts (NEVER publishes)
cms-edit save
# 9. Discard changes if needed
cms-edit discardCommand Reference
Navigation
| Command | Description |
|---------|-------------|
| open <slug> | Load a page/article by slug and start a session |
| open <id> --id | Load by Contentful entry ID |
| snapshot [-c] | Re-print content tree (-c for compact) |
| read [ref] [field] | Read all fields of a ref, or a specific field |
| diff | Show unsaved changes |
| save | Write all changes to Contentful as drafts |
| discard [--all] | Discard session changes |
Field Editing
| Command | Description |
|---------|-------------|
| set <ref> <field> <value> | Set a scalar field (string, boolean, number) |
| set <ref> <field> --file <path> | Set an Object/JSON field from a file (e.g. data) |
| set <ref> <field> --json '<json>' | Set an Object/JSON field from an inline JSON string |
| set <ref> <field> <entry-id> --link | Set an entry link field (e.g. template) |
| set <ref> <field> <refs-or-ids> --links [--append] | Set a content array (topContent, content, bottomContent, contents); replace by default, or append with --append |
| rtf <ref> <field> "<markdown>" | Set a rich text field from Markdown |
| rtf <ref> <field> --file <path> | Set rich text from a file |
| rtf <ref> <field> - | Set rich text from stdin (e.g. cms-edit rtf @c2 body - < file.md) |
| rtf embed <ref> <field> <entry-id> [--at N] | Insert embedded entry block; use --at N for 0-based position, omit to append |
| rtf embed <ref> <field> <asset-id> --asset | Insert embedded asset block |
Structure
| Command | Description |
|---------|-------------|
| add <type> --content-type <ct> [--after <ref>] [--parent <ref>] [--target topContent\|content\|bottomContent] | Create and link a new entry; --content-type is required (e.g. component, collection, externalComponent, person) |
| remove <ref> | Unlink from page (deletes if unreferenced draft) |
| move <ref> [--after <ref2>] [--before <ref2>] | Reorder within parent |
add — content type is always explicit:
cms-edit add "Hero" --content-type component
cms-edit add "Card Grid" --content-type collection
cms-edit add "Research chart" --content-type externalComponent
cms-edit add "Dr. Jane Smith" --content-type personFor content types that follow the ${contentType}Type naming convention (e.g. component → componentType, externalComponent → externalComponentType), the <type> argument is stored in that field. For content types without a type discriminator the <type> is used as the initial cmsLabel only.
Create
| Command | Description |
|---------|-------------|
| create page --slug /x --title "X" | Create a new page entry |
| create article --slug /x --title "X" --article-type-id <id> | Create a new article |
| create template --label "X" | Create a new template entry |
Templates
Create a template: cms-edit create template --label 'Campaign Landing'. Edit: cms-edit open <template-id> --id. List template IDs: cms-edit list --type template.
Links (CTAs)
Put the ref after the subcommand (e.g. links add @c5).
| Command | Description |
|---------|-------------|
| links list <ref> | List CTA links on an entry |
| links add <ref> --type external --label "X" --href <url> | Add an external link (requires --href) |
| links add <ref> --type internal --label "X" --slug /page | Add an internal link by page/article slug |
| links add <ref> --type internal --label "X" --id <entry-id> | Add an internal link by Contentful entry ID |
| links add <ref> --type download --label "X" --asset-id <asset-id> | Add a download link to an asset (requires --asset-id) |
| links remove <ref> <index> | Remove a link by index |
| links move <ref> <from> <to> | Reorder links |
Examples:
cms-edit links list @c5
cms-edit links add @c5 --type external --label "Download PDF" --href "https://www.example.com/whitepaper.pdf"
cms-edit links add @c5 --type internal --label "Pricing" --slug /pricing
cms-edit links add @c5 --type internal --label "About" --id 4xKj2abcDef
cms-edit links add @c5 --type download --label "Download PDF" --asset-id 5xKj2abcDef
cms-edit links remove @c5 1
cms-edit links move @c5 2 0Assets
| Command | Description |
|---------|-------------|
| asset search "<query>" | Search assets by title |
| asset info <asset-id> | Show asset details |
| asset set <ref> <field> <asset-id> | Set a visual/asset field |
Navigation Entries
| Command | Description |
|---------|-------------|
| nav open <slug-or-id> | Load a navigation entry |
| nav add --label "X" --slug /page | Add a navigation item |
Discovery
| Command | Description |
|---------|-------------|
| types <content-type> | List valid type-discriminator values for any content type (looks up the ${contentType}Type field) |
| colours | List valid backgroundColour and textColour values from the content model (one list each, no session required) |
| search "<query>" | Full-text search across entries |
| list --type <type> | List all entries of a content type (paginates automatically) |
types examples:
cms-edit types component # lists componentType values
cms-edit types collection # lists collectionType values
cms-edit types externalComponent # lists externalComponentType valuesScreenshot
Capture a PNG of a component, collection, external component, person, or page. Requires agent-browser (npm install -g agent-browser && agent-browser install). For @ref and --json-file, the app must be running at devBaseUrl (see .contentful-cms.json).
| Target | Command |
|--------|---------|
| Session ref (full-fidelity) | cms-edit screenshot @c0 — all types (component, collection, externalComponent, person) via convert API and /cms/preview/render-json |
| JSON file (no Contentful) | cms-edit screenshot --json-file path/to/entry.json — IBase* JSON; validates or screenshots without a session |
| By type (mock, no session) | cms-edit screenshot --component HeroSimple, cms-edit screenshot --collection CardGrid |
| Page | cms-edit screenshot (current page) or cms-edit screenshot /pricing |
Options: --out <path>, --full, --embedded, --wait <ms>, --url-only (print URL only), --json (machine-readable output). For --component / --collection: --param key=value (repeatable) to override showcase controls (e.g. --param backgroundColour=Navy --param textColour="Off White"). --width <px> and --height <px> set viewport size before capture (e.g. --width 375 for mobile).
Use --out before.png / --out after.png with agent-browser diff screenshot for visual diffing. See the screenshots skill for details.
Batch screenshots: The cms-capture-screenshots script (same package) captures multiple variants to <app-dir>/docs/cms-guidelines/screenshots/. Run it from the monorepo root so the output path is correct: node packages/contentful-cms/dist/bin/cms-capture-screenshots.js --variants /tmp/type-variants.json --app-dir apps/example-se2026.
export-converted
Export a session ref's entry as converted (IBase*) JSON via the app's convert API. Use the output with screenshot --json-file for custom variants without a session.
cms-edit open /your-page
# Snapshot shows refs; find the component ref (e.g. @c1)
cms-edit export-converted @c1 --out hero-base.jsonApp must be running at devBaseUrl. See example-brightline docs/cms-edit-hero-variants.md for a full workflow (Hero variants, viewport widths, custom params).
Machine-readable Output (--json)
All commands support JSON output via the global --json flag or the CMS_EDIT_JSON=1 environment variable (see JSON Mode above). In addition, a subset of data-query commands also accept a per-command --json flag:
| Command | --json output |
|---------|-----------------|
| list --type <type> --json | JSON array of entry objects including all fields |
| search "<query>" --json | JSON array of entry objects including all fields |
| read <ref> --json | JSON object for one entry including all fields |
| read <ref> <field> --json | JSON value for a single field |
| asset info <id> --json | JSON object for one asset |
Entry JSON shape (for list, search, read):
{
"id": "abc123",
"contentType": "article",
"title": "My Article",
"slug": "my-article",
"status": "published",
"updatedAt": "2024-01-01T00:00:00Z",
"fields": {
"title": "My Article",
"slug": "my-article",
"articleType": { "id": "entryId", "linkType": "Entry" },
"download": { "id": "assetId", "linkType": "Asset" },
"tags": ["tag1", "tag2"],
"body": "## Heading\n\nRich text rendered as Markdown."
}
}Fields are flattened to the space's default locale. Link fields become { id, linkType } objects. Rich text fields become Markdown strings.
Asset JSON shape (for asset info):
{
"id": "assetId",
"title": "My PDF",
"fileName": "report-2024.pdf",
"contentType": "application/pdf",
"url": "https://assets.ctfassets.net/...",
"width": null,
"height": null,
"size": 102400
}Progress messages from list are always written to stderr, so they do not contaminate the JSON on stdout.
Scripting example: build an article–asset mapping
# 1. Get all articles as JSON (no session required)
cms-edit list --type article --json > articles.json
# 2. For each article, fetch the download asset filename
jq -r '.[].fields.download.id // empty' articles.json | while read assetId; do
# Requires an open session for space resolution — run `cms-edit open /any-page` first
cms-edit asset info "$assetId" --json
doneGlobal Options
--space <name> Override the default space (from config)
--config <path> Custom config file path
--json Output all results as machine-readable JSON (see below)
--docs Print absolute path to this README and exit (for LLM or script tooling)Use cms-edit --docs to get the path to the README so an LLM or script can read it.
JSON Mode (LLM / machine-readable output)
Enable JSON mode to get machine-readable output from every command. Two ways to activate:
# Global flag (placed before the subcommand name):
cms-edit --json snapshot
cms-edit --json diff
cms-edit --json set @c2 heading "New title"
cms-edit --json add "Research chart" --content-type externalComponent --target content
# Environment variable (set once for the whole session):
export CMS_EDIT_JSON=1
cms-edit snapshot # → JSON
cms-edit diff # → JSON
cms-edit set @c2 data --file chart.json # → JSONJSON output shapes
Action commands (set, rtf, add, remove, move, save, discard, links, …):
{"ok": true, "message": "Set heading on @c2 to: New title"}
{"ok": false, "error": "Ref @c99 not found. Available refs: @c0, @c1, @c2"}
{"warn": "Entry is saved to Contentful but changes to the parent page are unsaved."}Multiple lines may appear for a single command (one per message). Errors go to stderr; warnings and results go to stdout.
snapshot / tree:
{
"rootType": "article",
"rootSlug": "/publications/study-1",
"rootId": "4xKj2abc",
"rootStatus": "published",
"spaceKey": "om1",
"fetchedAt": "2026-03-05T10:00:00.000Z",
"unsavedChanges": 1,
"pendingDeletions": 0,
"entries": [
{
"ref": "@c0",
"entryId": "abc123",
"contentType": "component",
"type": "RichText",
"label": "Introduction",
"status": "published",
"depth": 1,
"parentRef": null,
"parentField": "content"
},
{
"ref": "@c1",
"entryId": "def456",
"contentType": "externalComponent",
"type": "Research chart",
"label": "Figure 1: Cost-Efficiency",
"status": "draft",
"depth": 1,
"parentRef": null,
"parentField": "content"
}
]
}diff:
{
"hasChanges": true,
"modified": [
{
"ref": "@c1",
"entryId": "def456",
"contentType": "externalComponent",
"type": "Research chart",
"label": "Figure 1",
"isNew": false,
"fields": {
"heading": {"before": null, "after": "Figure 1: Cost-Efficiency"},
"data": {"before": null, "after": {"type": "bar", "categories": ["A", "B"]}}
}
}
],
"deletions": []
}read, list, search, asset info: same JSON shapes as their existing per-command --json flag.
Order of operations: article + CTA
To add body content and a CTA (e.g. PDF download) to an article:
- Open the article:
cms-edit open <article-slug>orcms-edit open <id> --id - Set or add body: If the article has a rich text component, use
cms-edit rtf @<ref> body "..."orcms-edit rtf @<ref> body --file path/to.md. If you need to add a new body component first, useaddthenrtf. - Add CTA at bottom:
cms-edit add CTA --content-type component --target bottomContent(or add to main content with--after @<ref>if you prefer) - Set CTA links: Use one of:
- External (e.g. PDF URL):
cms-edit links add @<ctaRef> --type external --label "Download PDF" --href <url> - Download (Contentful asset):
cms-edit links add @<ctaRef> --type download --label "Download PDF" --asset-id <asset-id>(get the ID fromcms-edit asset search "..."orcms-edit asset info <id>)
- External (e.g. PDF URL):
- Save:
cms-edit save
Order of operations: article + Research chart
To populate an article with a Research chart (external component):
# 1. Open the article
cms-edit open <article-slug>
# 2. Discover valid externalComponentType values
cms-edit types externalComponent
# 3. Add a Research chart to the content array
cms-edit add "Research chart" --content-type externalComponent --target content
# 4. Set the heading (scalar field)
cms-edit set @c2 heading "Figure 1: Cost-Efficiency of Data Automation"
# 5. Set the data field from a JSON file
cms-edit set @c2 data --file figure1-data.json
# 6. Set the additionalCopy footnote (rich text)
cms-edit rtf @c2 additionalCopy --file footnote.md
# 7. Review and save
cms-edit diff
cms-edit saveSnapshot Format
Page: /pricing [4xKj2abc | published] · om1
@c0 Component[HeroSimple] "Transparent Pricing" [changed]
@c1 Component[RichText] "How it works" [published]
@c2 Collection[CardGrid] "Plans" [published]
@c3 Component[Card] "Starter" [published]
@c4 Component[Card] "Pro" [draft]
@c5 ExternalComponent[Research chart] "Figure 1: Cost-Efficiency" [draft]
@c6 Component[CTA] "Get started today" [published]
@c7 Person "Dr. Jane Smith" [published]The type label follows the ${contentType}Type convention:
component→Component[<componentType>]collection→Collection[<collectionType>]externalComponent→ExternalComponent[<externalComponentType>]- Any content type without a type discriminator → capitalised content type ID (e.g.
Person)
Status badges:
[published]— live, no pending changes[draft]— never published[pending]— published but has newer draft in Contentful[changed]— has unsaved local changes (runsave)
Rich Text (Markdown) Support
The rtf command accepts basic Markdown:
# Heading 1 → heading-1
## Heading 2 → heading-2
**bold** → bold mark
_italic_ → italic mark
***bold italic***
[text](url) → hyperlink
- item → unordered list
1. item → ordered list
> quote → blockquote
--- → horizontal rule
`code` → code markMulti-space Usage
cms-edit --space om1 open /pricing
cms-edit --space brightline open /homeWhat This Tool Cannot Do
By design, the following operations are not available:
- Publish entries (no
publishcommand) - Unpublish entries
- Archive entries
- Delete published entries
- Upload new assets
These restrictions are enforced at two levels:
- Contentful Role — the management token should use a role with no publish permissions
- CLI — no publish/delete commands exist in this tool
All saves create draft versions that remain invisible to visitors until a human reviews and publishes them in the Contentful web app.
