CMS management API
Post-1.0. A versioned API for scripts, command lines and AI agents that manage one site’s content,
models and settings. It runs the same lifecycle services, model apply and admin handlers as the
admin UI. It is not the generated backend’s CRUD API. A model change is made directly with
schema:write, or proposed for a person to apply in the Content Builder.
- Machine contract:
openapi-v1.json(OpenAPI 3.1). - TypeScript types:
backend/src/lib/management/client-v1.generated.ts. - Command line and MCP server:
dee-wan-manage.mjs, downloadable from Settings → API. - Remote MCP:
POST /api/manage/mcp, the same operations over HTTPS with no download. - All three are generated from
backend/src/lib/management/contract.tsbynpm run docs:management-api.npm run check:management-apifails when any of them is stale.
Credentials
Section titled “Credentials”Create one in Settings → API → Management credentials. You need site:settings on the site.
- Give it a name, the scopes it may use and, optionally, the models it may touch.
- You can only grant what you hold now. A scope or model you do not have access to is refused with
422 grant_not_held, which names them. The form disables them. - The secret is shown once. Only its SHA-256 digest is stored. Copy it before you close the panel.
- Format:
dwm_followed by 43 base64url characters. - Revoke it on the same screen. A revoked secret stops working on its next request.
- No usage counter or last-used time is kept.
Authority
Section titled “Authority”A credential acts as the person who created it. On every request the API re-reads that person’s current permissions on the site and intersects them with the credential:
- scopes limit which commands it may call;
- models (if chosen) limit which content models it may read or write;
- the creator’s role limits everything else, including
update:ownownership rules, the permission each workflow transition names, and the role’s field and language limits (People and access): entry lists and reads leave out languages and fields the role may not read, and writes outside the role’s permissions returnfield_deniedorlocale_denied.
If the creator is deactivated or loses access to the site, the API returns creator_unauthorized.
If the creator loses a permission, the credential loses it at the same moment. A superadmin creator
gets no bypass: the credential remains limited to its scopes and models.
| Scope | Allows | Needs from the creator |
|---|---|---|
content:read |
Read site, models, entries, versions, dependents, proposals, pages, media. Always included. | read on the model |
content:write |
Create and update draft entries, add a language, restore an old version, change linked items (relate, related from). | create / update on the model; a version restore also content:restore |
content:propose |
Propose field changes for an editor to review. | read on the model |
pages:write |
Page editor save. | update on the page model |
media:write |
Upload media, edit a file’s alt text, focal point, loading and fetch priority, delete unused files, and create, rename, move and delete library folders and move files between them. | media:upload to upload, media:update to edit and for folders, media:delete to delete |
lifecycle:publish |
Publish and unpublish, with no second human confirmation. | update and the transition’s permission |
content:trash |
Move entries or single languages to Trash, and restore them. | delete on the model |
content:purge |
Delete a Trash item permanently. | delete on the model |
workflow:move |
Workflow steps that neither publish nor unpublish (Submit for review). | update and the step’s permission |
workflow:assign |
Name who acts next on an entry; list who could. | update on the model |
settings:write |
Read and change site settings: retention and review, missing-language fallback, media files, search, Site Chrome defaults, SEO, robots, redirects and menus. | site:settings; search uses site:infra instead |
schema:propose |
Propose a new model or a model change for a person to review; list and read those proposals. | model:apply |
schema:write |
Create, change, replace and delete models directly. | model:apply; a delete also model:delete |
data:import |
Run imports end to end: sources, jobs, upload, advance, mappings, pause, resume, cancel, undo, receipts and problem files. Each effect is still checked against the creator’s own permissions. | data:import; the effects also need what the admin import needs (model and entry writes, media:upload, model:apply, site:settings, publication) |
data:export |
Create exports, build them and read them in byte ranges. | data:export |
roles:write |
Create, change and delete the site’s roles, field and language limits included. A credential hands out only what it holds itself. | role:manage |
plugins:use |
List installed plugins, create one-time links that open a plugin view or entry action, and run declared plugin operations. | What the same launch or run needs in the admin: read on the entry (update for a destructive operation), or a granted model the creator reads, own entries included; a site operation needs site:plugins |
/roles lists the site’s roles with their whole permission sets: models, system and the
optional field and language limits (People and access).
Writes go through the same checks as Settings → Roles: unknown permissions answer 400
invalid_permissions with every reason, and a permission set beyond the credential’s authority returns 403
escalation_refused. The ceiling is the credential’s own narrowed permissions, not its person’s
whole role: a credential without content:write cannot give update, and one without
lifecycle:publish cannot give content:publish. A model wildcard ("*") is never within a
credential’s ceiling. PUT /roles/{id} replaces only the fields it names; permissions replaces
the whole set. Deletion is refused for a seeded role, a role someone holds, a role a Cloudflare Access rule
names, and the last role that can grant access.
Requests
Section titled “Requests”- Base path:
/api/manage/v1. - Send
Authorization: Bearer <secret>. The site comes from the credential. A request that sendsX-Site-Idor names a database is refused. - JSON bodies are closed: an unknown key is
invalid_request. - Every write needs
Idempotency-Key: 1–128 visible ASCII characters (0x21–0x7E, no spaces). The exceptions take no key and their Key cell below is empty: plugin launches, the redirect review (it writes nothing) and the four editing routes under/entries/{id}/editing. Reuse the same key and body on a retry; the first result comes back withreplayed: true(pages:created: false). A key reused for a different request isidempotency_key_reused. Publish, unpublish and move keys are kept 30 days; other keys 24 hours. Trash, restore, purge, assignment and new-language keys hold the exact first answer, so a retry of a purge replays even though the entry is gone. - A command claims its key before it changes anything. A second request with the same key and body
while the first still runs changes nothing and is
409 idempotency_in_progresswithRetry-After; retry it with the same key and body to get the first answer. A refused or failed command frees its key. Media metadata edits, media deletes, settings writes, proposals and every import and export step follow this rule; a replay answers the exact first response, never a fresh read. - A command never becomes safe to repeat merely because its key claim has aged. When the request that holds a key stops
answering, the next retry with that key settles the claim by what the command itself can prove:
- Written in one batch with its answer: media upload, edit and delete, media folders, model and
menu writes, Site Chrome and SEO settings, trash, restore, purge, assignment, a new language,
creating an import, an export, an import source or a role, deleting a role, and importing a media
source item. A claim with no answer proves nothing was written, so the retry runs the command.
When the write committed and only its answer was lost, the retry answers from what it wrote, with
replayed: true. A file a stopped upload stored is removed. - Kept in a record of its own, or safe to repeat: entry saves, publish, unpublish, move, proposals,
page saves, settings, plugin operations, import parts, role updates, comment settings,
moderation and deletes. The retry runs the command again. A moderation or delete repeated after
the first one committed answers
conflict(a delete:not_found) with the comment’s state now. - Steps of a job: export and import steps, review choices, pause, cancel and upload completion.
The claim records where the job stood before the step. When the job moved since, the retry
answers its state now, with
replayed: true, and runs nothing. When it did not move, an export step, a review choice, pause, cancel and upload completion run again: each is compare-and-set on that position or sets a target state.advanceImport,resumeImport,advanceImportUndoandretryImportUndorun again only after the first request ended with an error; when it stopped answering (a timeout, a lost connection), the claim stays pending.
- Written in one batch with its answer: media upload, edit and delete, media folders, model and
menu writes, Site Chrome and SEO settings, trash, restore, purge, assignment, a new language,
creating an import, an export, an import source or a role, deleting a role, and importing a media
source item. A claim with no answer proves nothing was written, so the retry runs the command.
When the write committed and only its answer was lost, the retry answers from what it wrote, with
- A claim that stays pending answers
409 idempotency_in_progresswithoutRetry-After: waiting does not settle it. Read the result (for an import,getImportJob), then send the command again with a new key if it is still needed. The claim expires with its key after 24 hours. - Media upload takes a file whose kind and format the site accepts (Settings → Site → Media files): an
image, a document, audio, video, or another file as a download. The same check runs for the admin,
this API, plugins, imports and site copies. The type is checked against the bytes; a file whose
bytes, extension or declared type say it is a program, a script or markup is refused
(
executable_file, orunsupported_media_typefor markup). Every media item includes itskind:image,document,audio,videoorfile. An image’swidthandheightcome from its own header; thewidthandheightfields are used only when the header provides neither value. edited_fromuploads the bytes as an edited copy of a library file (for example a crop made in another tool). The original is kept. The copy answersedited_from, takes the original’s tags, and takes its alt text and folder unless the request names them. An unknown id is404media_edit_source_not_found.- A media upload through this API buffers the whole file, so it takes at most 32 MiB whatever a kind’s
cap says. The admin streams larger documents, audio, video and other files up to
limits.streamed_max_bytes: 100 MB, or the larger plan limit the deployment declares inMEDIA_REQUEST_BODY_LIMIT_BYTES(at most 5 GB; see Workers limits). - Media upload is keyed on the file’s bytes, name and type and its
alt_texts,width,height,aspectRatio,on_duplicate,folder_idandedited_fromfields, not on the multipart boundary. The key commits in the same batch as the media row, so an exact retry returns the recorded result. - When the site already holds the same bytes,
on_duplicatedecides.ask(the default) answers409media_duplicatewith theexistingfile and how manycopieshold it, and stores and records nothing.use_existinganswers200with the existing file andreused: true; a retry replays it as200.copyuploads a separate copy. Another site’s files are never matched. - The media library is site-wide: a credential limited to some models still lists and uploads all media.
- Every media item includes its
folder_id, ornullfor Unfiled. Upload may name afolder_id; a reused duplicate stays where it is. Folders are logical: no stored file or address ever moves. /media/foldersanswers the whole tree depth first, each folder with itsrevision,depth, ownmedia_count,subtree_folder_countandsubtree_media_count, plusunfiled,totaland the limits: 5 levels, 500 folders per site, names of 1–80 characters unique under their parent regardless of letter case. Rename and move sendexpected_revision; deleting sendsexpected_revision,expected_foldersandexpected_mediaas read. A stale value is409media_folder_changedwith the currentfolder. Other refusals:media_folder_exists(with thefolderholding the name),media_folder_cycle,media_folder_too_deep,media_folder_limit,media_folder_not_found. Deleting a folder deletes every folder below it and no file: each file becomes Unfiled./media/moveputs 1–200 files in one folder, or in Unfiled withfolder_id: null.- Every media item also includes
default_focal({ x, y }fractions, ornull),loadingandfetch_priority.GET /media/{id}answers one item in the list’s shape. GET /media/{id}/usageanswers where a file is used, as the media library shows it:used_by(at most 50 entries this credential may read),hidden(references in content it may not read: counted, never described),at_least,total(nullwhen capped or unreadable),moreandunreadable.PUT /media/{id}changes what the media library edits:alt_texts(language code to text; a language left out keeps its text,""clears it),default_focal(images only,nullclears it; otherwise400 media_framing_not_image),loading(eager,lazy) andfetch_priority(high,low,auto). Send at least one. The file, its dimensions and its address never change here.POST /media/{id}/replaceaccepts a multipartfile, with the same media kind as the existing file. It keeps the media id, references, folder, tags, credit and framing; omittedalt_texts,default_focal,loadingandfetchPrioritykeep their current values. The verified replacement updates its name, type, size and dimensions. Its delivery URL may change: read the returnedurl, rather than relying on the old address. Published versions that pin the previous object keep those bytes until no version references them. Linked external media cannot be replaced. The same upload validation and atomic replacement used by the library applies. Reuse the sameIdempotency-Keyand file/fields to retry safely. The TypeScript operation isreplaceMedia, the CLI commandreplace-media, and the MCP toolreplace_media(remote MCP usescontent_base64,file_nameandid).GET /media/{id}/originalslists current originals by language (content:read), with objectid,lang,url, MIME type, byte length and dimensions. Private URLs arenull.PUT /media/{id}/originals/{lang}takes multipartfileandexpected_object_id; an empty string creates the first override, otherwise send the current object id.DELETEon the same path takes JSONexpected_object_id(nonempty). Both needmedia:writeandIdempotency-Key; stale pointers return409 media_changed. Only that language changes; the media id, references and other originals stay. Without an override, delivery follows the site’s language fallback, then the default original. Published version pins retain their exact bytes.POST /media/{id}/replacestill replaces the default original.- TypeScript:
listMediaOriginals,replaceMediaOriginal,removeMediaOriginal. CLI:list-media-originals,replace-media-original,remove-media-original. MCP:list_media_originals,replace_media_original,remove_media_original. POST /media/{id}/removedeletes a file nothing references. While anything does, it answers409 media_in_usewith the same usage fields;media_usage_unreadablewhen usage could not be read. Nothing is deleted then. A success answersstorage_cleanup:complete, orpendingwhen the record is gone but a stored copy could not be removed yet. MCP clients see it as destructive.GET /media/sourceslists the external media sources active plugins offer the site (a plugin grantedmedia:source).GET /media/sources/{installationId}/{sourceId}/itemsanswers one page of a source, at most 24 items, searched byq; sendnext_cursorback ascursorfor the next page. Each item has its title, file name, type, size, athumbnail_urlon the plugin’s origin (load it with no referrer) and itsattribution. Each source lists itsmodes:copy,referenceor both. Nothing is stored by browsing.POST /media/sources/{installationId}/{sourceId}/importwithitem_idandmodeadds one item to the library.modeis required and is one of the source’smodes; Dee Wan never infers it. Withcopy, Dee Wan fetches the file from the plugin and checks it like any upload: the site’s kinds, formats and size limits, and the new file never depends on the source again. Withreference, Dee Wan fetches nothing and keeps the plugin’s delivery URL and the item’s identity: the media answersexternal: true, itsurlis the delivery URL, its bytes cannot be replaced or edited (media_external_bytes_unavailable, 409) and deleting it removes only the library row.folder_idandon_duplicatework as on upload (forreference, the same plugin, source and item is the duplicate);use_existinganswers the existing file with200. The new file answersattribution(source name, author, license, links, and the plugin, source and item ids). Every media item includesattribution,nullfor files added any other way. Refusals:media_source_unavailable(404),media_source_mode_unsupported(422, the source does not declare thatmode),plugin_refused(422, with the plugin’srefusal),plugin_unreachable,plugin_response_invalidandmedia_source_file_unreachable(502),plugin_timeout(504), and the upload refusals such asmedia_format_not_enabled,media_kind_not_enabledandmedia_too_large. The audit records it as a media upload naming the source.- Rate limit: 120 requests per minute per credential. Over it:
429withRetry-After.
| Method | Path | Scope | Key |
|---|---|---|---|
| GET | /site |
content:read |
|
| GET | /models |
content:read |
|
| GET | /entries?model_id=… |
content:read |
|
| POST | /entries |
content:write |
required |
| GET | /entries/{id} |
content:read |
|
| PUT | /entries/{id} |
content:write |
required |
| GET | /entries/{id}/versions |
content:read |
|
| GET | /entries/{id}/versions/{versionId} |
content:read |
|
| GET | /entries/{id}/dependents |
content:read |
|
| POST | /entries/{id}/publish |
lifecycle:publish |
required |
| POST | /entries/{id}/unpublish |
lifecycle:publish |
required |
| POST | /entries/{id}/proposals |
content:propose |
required |
| GET | /entries/{id}/proposals |
content:read |
|
| GET | /entries/{id}/editing?locale=…&kind=… |
content:read |
|
| POST | /entries/{id}/editing/takeover |
content:write |
|
| PUT | /entries/{id}/editing/draft |
content:write |
|
| POST | /entries/{id}/editing/draft/discard |
content:write |
|
| GET | /pages/{id} |
content:read |
|
| POST | /pages/{id}/save |
pages:write |
required |
| POST | /pages/{id}/translations |
pages:write |
required |
| GET | /media |
content:read |
|
| POST | /media (multipart, file part) |
media:write |
required |
| GET | /media/folders |
content:read |
|
| POST | /media/folders |
media:write |
required |
| PUT | /media/folders/{id} |
media:write |
required |
| POST | /media/folders/{id}/remove |
media:write |
required |
| POST | /media/move |
media:write |
required |
| GET | /media/sources |
media:write |
|
| GET | /media/sources/{installationId}/{sourceId}/items |
media:write |
|
| POST | /media/sources/{installationId}/{sourceId}/import |
media:write |
required |
| GET | /media/{id} |
content:read |
|
| GET | /media/{id}/usage |
content:read |
|
| PUT | /media/{id} |
media:write |
required |
| POST | /media/{id}/replace (multipart file) |
media:write |
required |
| GET | /media/{id}/originals |
content:read |
|
| PUT | /media/{id}/originals/{lang} (multipart file, expected_object_id) |
media:write |
required |
| DELETE | /media/{id}/originals/{lang} |
media:write |
required |
| POST | /media/{id}/remove |
media:write |
required |
| GET | /languages |
content:read |
|
| POST | /entries/{id}/locales |
content:write |
required |
| POST | /entries/{id}/versions/{versionId}/restore |
content:write |
required |
| POST | /entries/{id}/transition |
workflow:move |
required |
| GET | /entries/{id}/assignees |
workflow:assign |
|
| PUT | /entries/{id}/assignment |
workflow:assign |
required |
| GET | /trash?model_id=… |
content:read |
|
| GET | /entries/{id}/trash |
content:read |
|
| POST | /entries/{id}/trash |
content:trash |
required |
| POST | /entries/{id}/trash/restore |
content:trash |
required |
| POST | /entries/{id}/trash/purge |
content:purge |
required |
| POST | /entries/relate |
content:write |
required |
| GET | /entries/{id}/related-from |
content:read |
|
| PUT | /entries/{id}/related-from |
content:write |
required |
| GET | /menus |
settings:write |
|
| GET | /menus/{key} |
settings:write |
|
| PUT | /menus/{key} |
settings:write |
required |
| POST | /menus/{key}/languages |
settings:write |
required |
| GET | /models/{id} |
content:read |
|
| GET | /models/proposals?state=… |
schema:propose |
|
| POST | /models/proposals |
schema:propose |
required |
| GET | /models/proposals/{id} |
schema:propose |
|
| POST | /models |
schema:write |
required |
| PUT | /models/{id} |
schema:write |
required |
| PUT | /models/{id}/folder |
schema:write |
required |
| PUT | /models/{id}/definition |
schema:write |
required |
| POST | /models/{id}/remove |
schema:write |
required |
| GET | /settings/editorial |
settings:write |
|
| PUT | /settings/editorial |
settings:write |
required |
| GET | /settings/language-fallback |
settings:write |
|
| PUT | /settings/language-fallback |
settings:write |
required |
| GET | /settings/media-files |
settings:write |
|
| PUT | /settings/media-files |
settings:write |
required |
| GET | /settings/search |
settings:write |
|
| PUT | /settings/search |
settings:write |
required |
| GET | /settings/chrome |
settings:write |
|
| PUT | /settings/chrome |
settings:write |
required |
| GET | /settings/seo |
settings:write |
|
| POST | /settings/seo/redirect-review |
settings:write |
|
| PUT | /settings/seo |
settings:write |
required |
| GET | /imports/sources |
data:import |
|
| POST | /imports/sources |
data:import |
required |
| GET | /imports?limit=… |
data:import |
|
| POST | /imports |
data:import |
required |
| GET | /imports/{id} |
data:import |
|
| PUT | /imports/{id}/archive/parts/{part} (binary) |
data:import |
required |
| POST | /imports/{id}/archive/complete |
data:import |
required |
| POST | /imports/{id}/advance?recheck=… |
data:import |
required |
| GET | /imports/{id}/findings |
data:import |
|
| GET | /imports/{id}/mapping |
data:import |
|
| PUT | /imports/{id}/mapping |
data:import |
required |
| GET | /imports/{id}/wordpress |
data:import |
|
| PUT | /imports/{id}/wordpress |
data:import |
required |
| GET | /imports/{id}/assignments |
data:import |
|
| PUT | /imports/{id}/assignments |
data:import |
required |
| GET | /imports/{id}/principals |
data:import |
|
| PUT | /imports/{id}/principals |
data:import |
required |
| POST | /imports/{id}/principals/accept-suggestions |
data:import |
required |
| POST | /imports/{id}/pause |
data:import |
required |
| POST | /imports/{id}/resume |
data:import |
required |
| POST | /imports/{id}/cancel |
data:import |
required |
| POST | /imports/{id}/undo |
data:import |
required |
| POST | /imports/{id}/undo/retry |
data:import |
required |
| GET | /imports/{id}/artifacts/{name} |
data:import |
|
| POST | /exports |
data:export |
required |
| GET | /exports/{id} |
data:export |
|
| POST | /exports/{id}/advance |
data:export |
required |
| GET | /exports/{id}/artifact?offset=…&length=… |
data:export |
|
| GET | /roles |
content:read |
|
| GET | /roles/{id} |
content:read |
|
| POST | /roles |
roles:write |
required |
| PUT | /roles/{id} |
roles:write |
required |
| POST | /roles/{id}/remove |
roles:write |
required |
| GET | /plugins |
plugins:use |
|
| GET | /plugins/manage-views |
plugins:use |
|
| GET | /plugins/content-actions?instance_id=… |
plugins:use |
|
| POST | /plugins/{id}/manage-views/{view}/launch |
plugins:use |
|
| POST | /plugins/{id}/content-actions/{action}/entries/{instanceId}/launch |
plugins:use |
|
| GET | /plugins/operations?instance_id=… |
plugins:use |
|
| POST | /plugins/{id}/operations/{operation}/invoke |
plugins:use |
required |
| GET | /rich-blocks |
content:read |
The four editing routes read and change the editing lease and the shared draft of one entry and
language (see Editing lease and Shared draft under Conflicts). They take no
Idempotency-Key and are not written to the audit history. They enforce the caller’s ownership and
field read scope, and answer only the fields the credential may read.
Read and write are separate grants. A creator who may change the entry but not read that row or
language (an own-only read on another person’s row, or no read grant in that language) can still
take over, write and discard. No answer carries the draft: GET is refused (not_owner or
locale_denied), takeover answers draft: null, 409 draft_conflict answers current: null, and
a written draft echoes only the creator’s own write. Such a creator cannot learn the draft’s
revision: discard without revision, then write with expected_revision: 0. A creator with no read
grant on the model at all is refused with 403 on every editing route, and nothing changes.
GET /entries/{id}/editing: querylocaleandkind(contentorpage).POST /entries/{id}/editing/takeover: body{ locale }.PUT /entries/{id}/editing/draft: body{ locale, kind, baseline_version_id, expected_revision, data }.baseline_version_idis the version the draft starts from, ornull;datais the draft’s field values.POST /entries/{id}/editing/draft/discard: body{ locale, kind }, and optionalrevisionto discard only that revision.
Refusals on these routes:
403 not_owner: the creator may read or change only their own entries, and does not own the entry’s row in that language (or the entry, when that language has no row yet).403 locale_deniedwithlocale: reading needs read in that language. Takeover, draft write and discard need update there, or create when the entry has no row in that language yet.403 field_deniedwithfields: ["slides"]: apagedraft for a creator who cannot read the page’s sections. A creator who cannot read the page title getstitleandbaseline.titleas""; their writes keep the stored title.423 lockedwithholderandrecovery: another person holds that language. The lease is checked again inside the write or discard, so a takeover between those checks also causes the write or discard to be refused and nothing changes. A refused write is kept as the creator’s own recovery draft (recovery). A refused discard keeps nothing:recovery: null.409 draft_conflictwithcurrent: the revision is stale.currentand every other draft answer hold only the fields the creator may read.currentisnullwhen there is no draft, or when the creator may not read that row or language.
/entries lists translation-group primaries of one model: model_id (required), published,
after, limit (1–50). A creator who may read only their own entries of that model gets
listing_owner_restricted; read those entries by id.
POST /entries and PUT /entries/{id} take relation lists under relations (field → list). A
relation field with different links in each language takes them under locale_relations (language
→ field → list) instead; see Different links in each language.
A relation field also takes { connect, disconnect, move }. Operations run in this order: disconnect, then connect, then move. A connect item is
an id (added at the end) or { id, position }; move is [{ id, position }] for items already related.
A position is exactly one of { before: id }, { after: id }, { start: true }, { end: true }; an
anchor is an id in the list at that step. Refusals (422): relation_position_anchor_missing,
relation_write_invalid, relation_write_duplicate (an id in both connect and move, in both
disconnect and move, or twice in move), relation_move_target_missing, and
relation_position_not_ordered (a position or move on a single-target field). Positions resolve
against the list stored when the save runs; expected_current_version_id still refuses a stale save.
Wherever a relation write takes an id it also takes { model, id }, naming the target’s model slug; see
A relation to several models.
Relate and Related from change the list of the language whose entry id is named.
/trash lists one model’s Trash the same way (model_id, after, limit). Each item is a whole
entry (scope: entry, addressed by its primary) or one language moved there alone
(scope: language), with deleted_at and purge_after (30 days later).
/menus reads the menus of the accepted Site Chrome and revision, the accepted version they
come from (null when none is accepted). A menu is key, name and variants: language code to
that language’s own items. placed_in maps a language to where the menu appears there.
/menus/{key} adds languages, served (per site language, the version it serves today under the
site’s strict or fallback rule, or null) and live (per version, then item id, the languages it
shows in today). An item whose page, post or archive is not live is hidden on the site.
PUT /menus/{key} creates or replaces one menu with all its versions: expected_revision, name,
variants and optional placements (language code to header/footer; a language left out keeps
its placements). An item points at a target: entry (model, entry = translation_group_id),
blog, archive (role category/tag/author, entry), url (https), email, phone or
path, or null for a heading with items under it. Every item needs a label; item ids are unique
within one version. POST /menus/{key}/languages adds one version: expected_revision, language
(a site language) and copy_from (a language to copy, with new item ids, or null for blank). It
answers menu_not_found 404, menu_language_exists 409, menu_language_missing or
menu_language_unknown 422. Each save is a new Site Chrome version, accepted at once; unaccepted
Site Chrome changes are rebased onto the accepted version with the menu included (draft: carried), or left for a
person (draft: needs_rebase). A stale expected_revision is 409 stale_document with
revision.
Import and export
Section titled “Import and export”The same imports and exports as Settings → Data transfer, without the admin. Every step runs the admin’s own import or export code with the same refusals; nothing here is a second import engine.
Import. POST /imports with format (tar, tar.gz, json, transfer, wxr), a new source
(label, system) or an existing source_id, and optionally destination. One import per site
runs at a time (409 import_already_active). Then:
- Upload the file in parts:
PUT /imports/{id}/archive/parts/{n}withContent-Type: application/octet-stream, 5 MiB per part, every part but the last exactly 5 MiB, numbered from 1. The format’s size limit applies. A part number is bound to its bytes’ SHA-256 before any byte is stored, here and in the admin upload alike: sending the same bytes again answers as before; different bytes for a bound part are409 import_part_conflict, whatever the key, and nothing of them is stored. No URL is ever fetched. POST /imports/{id}/archive/complete, optionally withclient_sha256of the whole file. Native.deewanuploads verify at most 32 MiB per call. Whileoutcome.moreis true, continue withadvanceor repeat completion with a fresh idempotency key. The saved checkpoint resumes by job ID.client_sha256_matchesis answered only after sealing.POST /imports/{id}/advanceuntil no further action is required. Each call runs one bounded step on the server: preparing the file, reading it, producing the work list, checking or importing a batch. A step the Worker cannot finish in one request continues on the next call. The whole import never runs in a single call.- When
decisionnames something, answer it and advance again: choose the Strapi mapping (PUT /imports/{id}/mapping), the WordPress choices (PUT /imports/{id}/wordpress), a site history’s assignments (PUT /imports/{id}/assignments), or fix whatGET /imports/{id}/findingslists.?recheck=truechecks a finished check again. A history transfer or WordPress import may also map the people it credits to members here:GET /imports/{id}/principalslists each principal (ref, sourcelabel,email,uses,suggestion,choice,mapped,problem) and the activemembers;PUT /imports/{id}/principalswith{ "ref", "user_id" }maps one (user_id: nullleaves it a label). Native.deewanreviews page principals and assignments with an opaquenext_cursor: pass it unchanged withlimit(1–100, default 50); the active-member choice list accompanies each principals page.POST /imports/{id}/principals/accept-suggestionsaccepts exact-email suggestions only for the requested native-transfer page: use the samecursorandlimitas its GET, then continue with the GET’snext_cursor. Legacy JSON and WordPress imports accept all undecided suggestions in one call. Existing choices stay. A member must be active on this site (409 principal_user_ineligible); an unknownrefis404 principal_not_in_transfer. Nothing creates or changes an account. The check blocks withprincipal_mapping_invalidwhen a mapped member lost access, and the run checks again before it writes. See Import and migration.
A later import from the same source creates what is new and skips what it already imported; it never changes content an earlier import wrote, and saving a new mapping changes nothing already imported. The one exception is a WordPress menu still exactly as the last import wrote it, which gains the items that now map (Import and migration). 5. A clean, current check imports on the next advance: advancing a checked job is the go-ahead.
Every import answer except artifacts is { "data": state }:
job:id,status,stage(upload,prepare,review,check,import,undo,done),format,source,plan_revision,upload(state,parts_received,bytes_received,part_size),progress,dry_run_state,undo_state,final_status,error, times.findings: counts ofblocking,warningandinfo.decision:null, or{ kind, message }withkindone ofupload_needed,mapping_required,wordpress_mapping_required,findings_block,paused,blocked,cancel_pending,undo_refused,undo_failed.more: another advance would run a step now.next_actions: the operations currently available.stepandoutcome: what this request ran, ornull.
pause stops after the current batch; resume continues a paused job; cancel keeps what was
already imported and writes the cancelled receipt (decision.kind: cancel_pending while file
cleanup is still open). POST /imports/{id}/undo reverses one batch per call and is destructive;
undo/retry returns refused units to the queue where the admin allows it.
GET /imports/{id}/findings?limit=&cursor= reads at most 500 findings (default 100), worst first and
then in the order they were found; pass next_cursor as cursor to read on (null after the last).
GET /imports/{id}/mapping, GET /imports/{id}/wordpress, GET /imports/{id}/assignments and
GET /imports/{id}/principals take
the same limit (at most 500, default 200) and cursor. Their long lists hold one page in the
admin’s order: Strapi types, normalized.skipped_fields, each list of normalized.group_keys and
review.unresolved; WordPress posts, pages and entries; history assignments; principals
and members. totals counts
the full list at that location in the response, and every other field, mapping included, comes whole on every
page.
GET /imports/{id}/artifacts/{name}?offset=&length= serves a byte range of receipt, problems
(problem-rows.csv), entries, media (the manifests), partition or plugin_data (the
WordPress plugin data file, see Import and migration), the same bytes the admin
downloads, at most 4 MiB a request, as application/octet-stream with the X-Artifact-* headers
below; X-Artifact-Content-Type names the file’s own type. A missing job and another site’s job both
answer 404 job_not_found.
Export. POST /exports with product (bundle, content, csv, xlsx, history), optional models and locale. Then POST /exports/{id}/advance until more is false. Site copies and history use the resumable .deewan format; content uses JSON Lines. Each advance keeps the next immutable page or advances multipart assembly. Assembly can take several calls. The finished artifact records the file’s size and complete SHA-256; only then are page objects removed. pages lists each built page’s checksum, size, cursors and readable state. CSV and XLSX retain their own limits.
GET /exports/{id}/artifact?offset=&length= reads the finished file, at most 4 MiB a request; before
it is finished this is 409 export_not_final. Add page=<n> to read one kept page while the export
runs; a page removed after finishing is 409 export_page_gone. Every read answers
application/octet-stream with X-Artifact-Offset, X-Artifact-Length, X-Artifact-Total,
X-Artifact-Sha256 and X-Artifact-Content-Type of the whole file, X-Artifact-Next-Offset (absent
at its end) and, for a page, X-Artifact-Page and X-Artifact-Next-Page (at the page’s end, when the
next page is built). A stored file that is not the recorded one is 409 export_artifact_changed;
past the end is 400 artifact_range_invalid. Two advances at once never lose or duplicate a page: the
second answers the export as the first left it. Expired and failed exports are refused as in the admin.
Every write needs Idempotency-Key. History records the operation, job, format, phase and outcome;
never file contents, content values, mappings, findings text or downloaded bytes.
Site settings
Section titled “Site settings”settings:write reads and changes one typed section per area. Each write names the revision it
read; a stale one writes nothing. Each write uses the same validator and the same stored value as
its Settings screen, and records management.settings.update beside that screen’s own entry.
The creator must hold site:settings now; search needs site:infra instead.
| Section | Read | Write | Revision |
|---|---|---|---|
| Retention and review | versions_to_keep, review_flow |
either, or both | expected_revisions.versions_to_keep, .review_flow |
| Missing language | policy (strict or fallback_to_default), chains, languages |
policy, chains (replaced whole) |
expected_revisions.policy, .chains |
| Media files | kinds (each enabled, max_bytes, formats), limits (min_bytes, buffered_max_bytes, streamed_max_bytes, kind_max_bytes) |
kinds (replaced whole; images stay on; a kind without formats takes its defaults) |
expected_revision |
| Search | enabled, models, weights, modes, what may be added or tuned |
enabled, models, optional weights, optional modes (word, english_stem or substring per model; absent is word; english_stem matches whole words in a language that is not English) |
expected_revision |
| Site Chrome defaults | footer.note per language, routes |
footer, routes |
expected_revision (accepted Site Chrome version, or null) |
| SEO | seo defaults, sitemap, robots, redirects |
any of them | expected_revision (accepted Site Chrome version, or null) |
- A stale settings revision is
409 settings_changed_concurrentlywithfield. A change that names a setting without its revision is400 invalid_request. - Retention and review, missing language, media files and search keep their key with the setting, in
one write. A retry with the same key and body gets the first answer, even after the revision moved.
A write that was saved but not finished answers
503 temporary_failure; the retry finishes it and replays the answer. - The validators answer by name, for example
invalid_versions_to_keep,review_flow_items_in_progress(entries wait in review; they are listed),fallback_language_unknown,fallback_cycle,invalid_media_files,site_search_model_invalid,site_search_weights_invalidandsite_search_mode_invalid. - Site Chrome defaults and SEO are saved as a new accepted Site Chrome version, like menus:
unaccepted changes are carried (
draft), and a stale revision is409 stale_document. - A social image or logo is named by
og_image_media_id/logo_media_idfrom this site’s media library; the server records its address and size. - Redirects are replaced whole. An exact source that is a live address, a reserved Blog address or a
named route is refused. Changing a pattern rule needs
redirect_review_digest: post the rules to/settings/seo/redirect-review, read which live pages each pattern covers, and send itsdigest. Without it the answer is409 redirect_review_stalewith the review. - Not here: secrets, passwords, sign-in methods, identity providers, plugin secrets and tokens, deployment, source providers, domains and other infrastructure.
Visitor comments
Section titled “Visitor comments”Comment operations run the admin comments route with the credential’s narrowed permissions, so its
rules, refusals and conflict answers are the admin’s (see
Visitor comments). Every write needs Idempotency-Key and
is recorded in History as management.comment.moderate, management.comment.delete or
management.comment.settings: the counts and target state, never a comment’s text or email.
| Operation | Scope | What it does |
|---|---|---|
GET /comments/settings (getCommentSettings) |
settings:write |
Every model and whether its entries take comments. |
PUT /comments/settings (updateCommentSettings) |
settings:write |
{ model, enabled }: opens or closes one model. Closing hides comments; nothing is deleted. |
GET /comments (listComments) |
lifecycle:publish |
Newest first, 50 a page; state (default pending), model, entry, lang, cursor. No email. |
GET /comments/{id} (getComment) |
lifecycle:publish |
One comment with its private email, parent and entry. |
POST /comments/moderate (moderateComments) |
lifecycle:publish |
{ to, comments: [{ id, expected }] }, at most 25, each only if still in the state read. |
POST /comments/publish-imported (publishImportedComment) |
lifecycle:publish |
Shows one comment imported without consent. Consent stays false. |
POST /comments/remove (deleteComments) |
content:purge |
Deletes at most 25 comments with their replies and reactions. Also needs lifecycle:publish and delete on each comment’s model. |
- A comment whose state changed since it was read answers
conflictwith its current state; one imported without consent answersconsent_missingto an approval. - Reactions are visitor-facing and have no management write. A listed comment carries its reaction counts.
Conflicts
Section titled “Conflicts”Every write names the state it was made against. A stale value is refused and writes nothing.
- Entry update:
expected_current_version_id. Stale:409 version_conflictwithcurrent_version_id. A create or update names addresses inslugs, one per language ({ "fr": "bonjour" }); a language left out keeps its address. A refused address namesfield: "slug"and itslocale. A create in a language the site does not have is refused400 unknown_localewithlang. A language removed while the create runs gives409 language_changed, and nothing is written. - Publish:
to,expected_workflow_revision,expected_current_version_id. A newer draft is never published silently:409 scheduled_target_stale. A workflow state that has changed:409 workflow_conflictwithrevision. - Unpublish:
to,expected_workflow_revision,expected_published_version_id, andacknowledged_dependentsequal toidsfrom/entries/{id}/dependentswhen other content references the entry. - One language: add
localeto a publish or unpublish to move that language only.tois checked against that language’s own state inworkflow.locales, and an unpublish pins that language’s own published version. Withoutlocaleevery language moves. - Page save:
expected_current_version_idandexpected_facts_digestfromGET /pages/{id}. A save carries one language (locale) and changes that language only; each language owns its sections. - Page language start:
locale(a language of the page with no page yet, or a site language the page does not have yet),source(another language of the page to copy, ornullfor blank) andexpected_current_version_id. A copy keeps section order, appearance, values and shared-section links under new section ids.409 translation_exists,422 translation_source_unavailable. - Proposal:
base_version_idmust be the entry’s current version. - Editing lease: while another person holds an entry and language open in the editor, a save,
move, assignment, restore or Trash of that entry and language is refused
423 lockedwith theholder.GET /entries/{id}/editing?locale=&kind=reads who holds it and the shared draft (kind:contentfor the entry editor,pagefor the page composer);POST /entries/{id}/editing/takeoverends that lease at once (the editor keeps its work as a recovery draft) and nobody holds it afterwards. Your own open editor never blocks your credential. An expired lease never blocks. - Shared draft:
PUT /entries/{id}/editing/draftnameskindandexpected_revision(0when there is none); stale:409 draft_conflictwithcurrent.POST /entries/{id}/editing/draft/discardremoves it. A draft is never a version; an entry update clears it. - Move:
toandexpected_workflow_revision, optionalnoteandlocale. A step that would publish or unpublish is422 transition_kind_mismatch: use publish or unpublish for it. A move withoutlocalebetween two unpublished stages leaves a language that is already published where it is; unpublish that language by itself. A move clears the assignment, as it does in the admin. - Assignment:
expected_workflow_revision,assignee_id(a person from/assignees, ornullto clear) and optionaldue_date(YYYY-MM-DD, today or later). Stale:409 workflow_conflictwithrevision. Someone who cannot act on the entry:422 assignee_cannot_act. - Version restore:
expected_current_version_id. The old version becomes a new current draft; what is live does not change. Stale:409 version_conflict. Optionallocalerestores that language only: its fields, its own relation lists and its address come from the version; shared fields, shared relations, media and every other language stay as they are. A language the version or the entry does not contain:422 restore_locale_missing. It needsrestorefor that language. - New language:
langfrom/languages, optionalslug(null: no address; omitted: the primary’s address, suffixed when this language already holds it). It starts empty; the next entry update writes it. Already there:409 locale_existswith itsinstance_id. - Trash:
scope: entrymoves every language;scope: languagemoves one non-owner language (owner_languagefor the owner). Live content is refused with409 content_liveandlive_languages: unpublish it first. A restored entry is a draft. - Restore from Trash:
GET /entries/{id}/trashnamesconflicts(blocking) andreferences(links that will not work). Restore is refused with409 restore_conflictwhile another item uses the address. - Purge:
confirmrepeats the entry id from the path. It deletes the item, its history and its visitor comments, and cannot be undone.GET /entries/{id}/trashnames the comments it will delete incomments; the answer names those deleted incomments_deleted. - Relate:
ids(1–25 entries),field(a many-to-many relation field they all have),connectand/ordisconnect(1–10 linked entries in all, none in both) andexpected_versionsnaming every entry’s current version. Each entry is its own draft save and gets its own result:done,unchanged(already as asked; nothing written),covered(another language of the same entry did it; for a field with different links in each language the first selected language changes every selected language’s own list in one save) orrefusedwithreason, for exampleversion_conflictorbatch_relation_exclusivefor a one-to-many field. A refusal stops nothing else. Nothing is published. - Related from:
GET /entries/{id}/related-fromlists every relation field of any kind (one-to-one, many-to-one, one-to-many, many-to-many) whose target is this entry’s model, asgroupsby source model and field withrelation_type. Each source has its displaylabel,draft/live,version_id,can_changeandreadable; a source the credential may not read has noslugorlabel.PUTtakesfield_id,source_id,action(addorremove) andexpected_source_version_id. Both needreadon this entry; a change also needsupdateon the source. It is the source’s own draft save: the source’s permissions, field rules, cardinality, one-owner rule, hierarchy rule and version check apply. It never publishes. Stale:409 version_conflict. A to-one source that already points elsewhere is refused with409 related_from_replace_unconfirmed, namingcurrentandnext(instance_id,slug,label; names the credential may not read are null). Sendreplace_target_idset tocurrent.instance_idto confirm that exact replacement. The answer isoutcome(savedorunchanged), the source’s newversion_idand the updatedrelated_from.
GET /entries/{id} returns the workflow state, revision and the transitions this credential
may take: kind: publish | unpublish with lifecycle:publish, kind: move with workflow:move.
to must be one of them; publish and unpublish refuse a step of another kind with
transition_kind_mismatch, and so does a move.
Proposals for editor review
Section titled “Proposals for editor review”An integration that should not write directly can propose field changes instead:
{ "base_version_id": "<current version id>", "fields": { "title": "A clearer title" }, "note": "Why"}- Up to 50 value fields of the entry’s model, at most 64 KiB of values, a note of at most 2,000
characters. Relation and media fields cannot be proposed (
proposal_field_unsupported). - Nothing changes until a person applies it. The entry editor shows each open proposal above the fields and the proposed value beside each field it names, with Apply and Dismiss.
- Apply is the ordinary save, run as the person applying it and pinned to
base_version_id. Their validation, permissions, ownership rules and workflow apply. If the entry changed after the proposal was made, Apply refuses withproposal_staleand writes nothing. - The editor disables Apply while it has unsaved edits. Dismiss leaves the entry as it is.
- A proposal’s
sourcenames who made it:{ "kind": "management", "id", "label" }for a credential, or{ "kind": "plugin", "id", "label", "requested_by" }for a plugin installation acting for a person (docs/plugins/protocol-v1.md). The editor shows a plugin proposal asProposed by <plugin> · requested by <person>. Listing an entry’s proposals also includes{ "kind": "ai", "id", "label" }, whereidnames the batch AI job. Each proposal includesrevision; optionaloriginal_fieldscontains the readable baseline values for the proposed fields.
Direct model changes
Section titled “Direct model changes”schema:write changes models without a review step. Each write runs the Content Builder’s own
apply or delete: the same checks, breaking-change refusals, relation rules, grant moves and audit.
GET /models/{id}answers the wholedefinitionandrevision.- Write against that
revision:
-
POST /modelswith{ "definition": { "model": …, "fields": [ … ] } }creates a model. No field ids. -
PUT /models/{id}withexpected_revisionandmodel(name,slug,description,settings,enableI18n) changes the model and keeps its fields. -
PUT /models/{id}/folderwith{ "folder": "Blog" }(1 to 40 characters, ornull) puts the model in a content navigation folder or takes it out. It is display only: noexpected_revision, the modelrevisiondoes not move, andlistModelsshows it asfolder. The answer ismodel_id,slugandfolder. -
PUT /models/{id}/definitionwithexpected_revisionand the completedefinitionreplaces it: keep a field by itsid, add one without an id, leave one out to remove it. -
POST /models/{id}/removewithexpected_revisionandconfirm(the model id again) deletes a model that holds no content. -
The answer is
model_id,slug,created_field_idsandrenamed_from. After a rename or a delete,dependents.completesays whether all role grants and token scopes naming the old slug were updated or removed; it is absent on a replay. -
revisionchanges with any change to the model or its fields. A stale one is409 model_changedand writes nothing. -
A change unsafe for existing content is refused with
409 model_change_blockedandchanges(kind,field,affected_count,message,suggestion). Nothing is overridden: migrate or clear the content first. -
Other refusals keep the Content Builder’s reasons, for example
duplicate_field_key,field_not_found,unique_unsupported,model_name_collision,stale_role_grant,stale_token_scope,model_not_emptyandmodel_has_bound_queries. -
The creator must hold
model:applynow, andmodel:deletefor a delete; without it the answer is403 missing_permission.
Schema proposals
Section titled “Schema proposals”Instead of changing a model, an agent can propose a new model or a changed definition; a person reviews it in the Content Builder and applies it through the ordinary save, breaking-change check and apply process.
GET /models/{id}answers the model’s wholedefinition(modelandfields, each field with itsid),updated_atandrevision.POST /models/proposalswith the edited definition:
{ "mode": "update", "model_id": "<model id>", "base_updated_at": "<updated_at from getModel>", "definition": { "model": { "name": "Article", "slug": "article", "enableI18n": true }, "fields": ["…"] }, "note": "Why"}definitionis complete: keep each field by itsid, add a field without one, leave a field out to remove it. Field order is array order.config,derived,uiandvalidationare JSON text, as the Content Builder stores them.- A new model is
{ "mode": "create", "definition": { … }, "note": … }, with no field ids. - At most 200 fields and a note of at most 500 characters.
- The definition is read and checked by the same validation used when applying a model. What the apply refuses is refused here
with the same reason, for example
duplicate_field_key,field_not_found,unique_unsupported,model_slug_takenandmodel_name_collision. If the model changed sincegetModel, the answer is409 model_changedwithcurrent_updated_at. - Changes that would break existing content are allowed, and listed in
changes(kind,field,affected_count,message,suggestion). The person sees the same check again before applying. - The creator must hold
model:applynow; without it the answer is403 missing_permission. - Content Builder → Models lists open proposals with Review and Dismiss. Review opens the
editor with the proposed definition; Save applies it as an ordinary model change, as the person
saving, and marks the proposal
appliedin the same write. They can edit it first. - A proposal is pinned to
base_updated_at. Once the model changes it isstale: true, its apply is refused with409 schema_proposal_stale, and it can only be dismissed. GET /models/proposalslists the site’s proposals, newest first (statefiltersopen,applied,dismissed).GET /models/proposals/{id}reads one.
Plugin launches
Section titled “Plugin launches”An agent can create a launch link to open an installed plugin for a person. Creating the link does not run the plugin; invoking a declared operation does.
GET /pluginslists installed plugins:state(activeordisabled),launchable, and for an active plugin itsmanage_viewsandcontent_actions. They come from the manifest stored at install or upgrade.GET /plugins/manage-viewsandGET /plugins/content-actionslist the same entries flat.instance_idkeeps only the actions that entry offers.- A content action lists
model_ids: models granted to the plugin that this credential may read. GET /rich-blocks(content:read) lists each installed plugin’s rich text block definitions: fields, rules, sample values, render tree and migrations, as<plugin id>/<name>. Write a block into a rich value as its canonical figure (see Blog → Plugin blocks). Only an active plugin’s blocks can be added or changed.- Only launches the credential could open now are listed, as entries and as tools. A view needs a
granted model the creator reads, own entries included, inside the credential’s models. An action
needs at least one model in
model_ids. A permission change shows on the next request. A launch sent anyway is still checked in full. POST /plugins/{id}/manage-views/{view}/launchandPOST /plugins/{id}/content-actions/{action}/entries/{instanceId}/launchcreate one launch link. The answer is201withoutcome: launch_created,url,expires_atandsingle_use: true.- The link carries one launch code. It works once and expires 60 seconds after it is made. A person opens it in a browser and the plugin acts as the credential’s creator, exactly as when that person opens the plugin in the admin. Nothing reaches the plugin until then, and the answer never says the plugin did anything.
- Once opened, the plugin works within its own grant and the creator’s permissions, limited to the
credential’s models and capped by its scopes. The delegation carries only the plugin capabilities
the scopes allow:
content:readgivescontent:readandworkflow:read;lifecycle:publishgivesworkflow:publish,workflow:unpublishandworkflow:locale;content:writegivescontent:write,content:createanddraft:write;content:trashgivescontent:delete;media:writegivesmedia:write,media:read,media:updateandmedia:source;content:proposegives the same name. A plugin command outside that iscapability_refused. - Each launch rechecks the creator’s current permissions, the credential’s models and the plugin
grant, the same check the admin runs. A disabled plugin is
409 installation_not_active; an uninstalled one, or one on another site, is404 not_found. - No
Idempotency-Key: each call makes a new link. Unused links expire.
Plugin operations
Section titled “Plugin operations”A plugin manifest may declare operations: a small form and a result Dee Wan renders itself. An agent runs one and gets the result back.
GET /plugins/operationslists the operations this credential may run now:scope(siteorentry),destructive,input(the declared fields),input_schema(JSON Schema ofinput),result(the result components) andmodel_ids(for an entry operation, the models it may run on).instance_idkeeps only that entry’s operations.GET /pluginscarries the same list per plugin.- An operation is listed only while the plugin is active and its grant holds every capability the
operation requires, inside what the credential’s scopes allow. An entry operation also needs a
granted model the creator reads inside the credential’s models; a destructive one needs
update. A site operation needssite:plugins, and the plugin then acts for the creator on the granted models they read inside the credential’s models, capped by its scopes. Scheduling’sreleases,release-editandrelease-runare such operations. A grant or permission change shows on the next request. POST /plugins/{id}/operations/{operation}/invokeruns it. The body is{ "instance_id"?: "…", "input"?: { … } };instance_idis required for an entry operation and refused for a site one.inputis closed: an unknown key or a value outside the field’s bounds is400 input_invalidwithissues.- The answer is
200withoutcome: completedandresult.blocks:text,rows,links(https only) andjson. The sameIdempotency-Keyand body return the first result withreplayed: true; the key with another body is409 idempotency_key_reused. A replay is checked like a new run first: an operation no longer offered to this credential is404 operation_unavailable, not the old result. - Refusals:
404 operation_unavailable(unknown, or not offered to this credential now),422 plugin_refused(the plugin’s ownrefusal: { code, message }),504 plugin_timeout,502 plugin_unreachable,502 plugin_response_invalid,409 plugin_unavailable(the plugin was disabled, narrowed or changed while it ran),409 invocation_in_progress. - The plugin runs it as the credential’s creator. It never receives a credential: core sends a
one-time code, and the plugin exchanges it with its own installation token. The run is audited as
plugin.operation.runwith digests of the values and the result, not the values themselves.
Plugin content policies
Section titled “Plugin content policies”A plugin may declare content policies: checks Dee Wan asks before a save, a restore, a deletion, a
publication, an unpublication or a workflow move. An entry update, a page save, a version restore, a Trash command and a transition through this API invoke the same policy checks as their editor equivalents, with
caller.via: management_credential. GET /plugins lists each plugin’s policies with their kind
and mode.
- The answer carries
policy_results: a policy that warned, one set to warn that did not answer (outcome: unavailable, withfailure), or one that transformed the saved content (outcome: transformed,fieldsnaming what it changed). Each namesinstallation_id,plugin_name,policy_id,policy_label,locale,messageandfields. The write is done; do not send it again. After a transform, read the entry back for the stored values. 422 plugin_policy_transform_invalid: a policy’s transformed content broke a rule.causeholds the rule’scode,messageanddetail. Nothing was written.422 plugin_policy_transform_conflict: two policies changed one field differently. Nothing was written.422 plugin_policy_refused: a policy said no. It namesplugin,policy,policy_label,locale, the plugin’scodeandmessage, andfields. Nothing was written.503 plugin_policy_unavailable: a policy set to block did not answer. It names the policy and thefailure. Nothing was written; try again later.429 plugin_policy_budget_exhausted: one request asked policies more often than it may. Send fewer entries at once.
Command line and MCP
Section titled “Command line and MCP”dee-wan-manage.mjs is one file with no dependencies. It needs Node 18 or newer. Download it from
Settings → API. The credential is DEE_WAN_MANAGEMENT_TOKEN when it is set, otherwise the saved
login for the origin (see Sign in from the terminal). The CMS origin is
--url or DEE_WAN_URL. The origin must use https (http only on localhost, 127.0.0.1 or
[::1]).
export DEE_WAN_MANAGEMENT_TOKEN=<credential>node dee-wan-manage.mjs helpnode dee-wan-manage.mjs list-entries --url https://cms.example --model-id <model id>node dee-wan-manage.mjs create-entry --url https://cms.example --body-file entry.jsonnode dee-wan-manage.mjs upload-media --url https://cms.example --file photo.webpnode dee-wan-manage.mjs launch-plugin-manage-view --url https://cms.example --id <installation id> --view calendar- Each command is one operation above, named in kebab case (
publishEntry→publish-entry). There is no raw request mode. - Writes always send an
Idempotency-Key. The key is printed to stderr; pass it back with--key <key>to retry safely. - Exit
0on success,1when Dee Wan refused (the answer is printed),2for usage, network or credential problems.
Sign in from the terminal
Section titled “Sign in from the terminal”node dee-wan-manage.mjs login --url https://cms.examplenode dee-wan-manage.mjs status --url https://cms.examplenode dee-wan-manage.mjs logout --url https://cms.examplelogin signs you in with your own account, like Connect an app:
- It reads
/.well-known/oauth-authorization-server. The issuer must be the origin, and the token, registration and revocation endpoints must live on it. PKCES256must be supported. - It starts a listener on
127.0.0.1only, on a port the system picks, and registers a client whose one redirect URI ishttp://127.0.0.1:<port>/callback. - It opens
/connectin the browser with PKCES256and a randomstate.--no-openprints the address instead.--scope "content:read content:write"asks for fewer scopes; without it every scope is asked for. You pick the site on the consent page. - The callback must be
GET /callbackwith the samestate, the issuer asiss, and exactly onecode. Other paths answer404. Cancel ends the login with exit1. After 5 minutes it gives up with exit2. - It exchanges the code and saves the login.
A failed or cancelled login writes nothing. A saved login stays as it was.
The saved login:
- One per origin, in the operating-system config directory:
~/Library/Application Support/dee-wanon macOS,$XDG_CONFIG_HOME/dee-wanor~/.config/dee-wanon Linux,%APPDATA%\dee-wanon Windows. The directory is0700and the file0600(on Windows the permissions are best effort). - It holds the origin, client id, site, scopes and the current refresh token. Never an access token. No command prints a token.
- Every command renews it: it takes a lock for the origin, uses the refresh token, writes the new one to a temporary file in the same directory, flushes it and renames it over the old one. Two commands at once wait for each other, so a refresh token is never used twice. A lock left by a process that died, or older than 2 minutes, is cleared.
- When Dee Wan no longer accepts it (revoked, expired after 30 days unused, or the person lost the
site), the command exits
2and asks you to runloginagain. The file is kept. - A new
loginfor the same origin replaces it and revokes the old connection. DEE_WAN_MANAGEMENT_TOKENalways wins. It is never saved.statussays when it overrides the saved login.
status shows the origin, the saved site and scopes, whether a client id is saved, and whether
DEE_WAN_MANAGEMENT_TOKEN is set. It exits 2 when there is neither. logout revokes the connection
at the revocation endpoint first and then deletes the file. When revocation fails the file is kept
and it exits non-zero; run it again.
node dee-wan-manage.mjs mcp --url <origin> runs an MCP server over stdio. Its tools are the same
operations, named in snake case (publish_entry), with the contract’s input schemas. Like the
remote server, it lists only the tools the credential’s scopes allow (read from GET /site), and
the plugin tools below. A write tool takes an optional idempotency_key and returns the key it
used. A call answers structuredContent { status, idempotency_key?, body } and the same object as
JSON text. Settings → API shows a client configuration to copy:
{ "mcpServers": { "dee-wan": { "command": "node", "args": [ "/path/to/dee-wan-manage.mjs", "mcp", "--url", "https://cms.example" ], "env": { "DEE_WAN_MANAGEMENT_TOKEN": "<your credential>" } } }}Without env, the MCP server uses the saved login for --url and renews it under the same lock
while the session runs.
The upload tool reads a local file path on the machine that runs the server.
Import and export on the command line:
node dee-wan-manage.mjs create-import --body '{"format":"wxr","source":{"label":"Old blog","system":"wordpress"}}'node dee-wan-manage.mjs upload-import-part --id <job id> --file export.xmlnode dee-wan-manage.mjs advance-import --id <job id> --follownode dee-wan-manage.mjs create-export --body '{"product":"bundle"}'node dee-wan-manage.mjs advance-export --id <export id>node dee-wan-manage.mjs read-export-chunk --id <export id> --out site.jsonupload-import-part --filesends the whole file in 5 MiB parts, then completes the upload with the file’s SHA-256. Each part’s key comes from its bytes, so running it again replays; changed bytes are refused.--part <n>sends one part only. The stdio MCP tool takesfile_pathand an optionalpart.advance-import --followadvances step after step, one line per step on stderr, and stops on a decision, at the end or on a refusal; the last state is printed as JSON. Over MCP each call is one step.read-export-chunk --out <file>reads the finished export chunk by chunk, checks its SHA-256 and only then writes the file; with--page <n>it saves one kept page.--finishfirst advances the export until it is finished, each step under a key derived from the export and the step, so running it again after a lost answer replays instead of building twice.read-import-artifact --out <file>saves a whole import artifact the same way.get-import-findings,get-import-mapping,get-word-press-review,get-history-assignmentsandget-import-principals --allread every page into one answer.- On
429, and on409 idempotency_in_progresswithRetry-After, the command waits forRetry-Afterand retries with the same key. WithoutRetry-Afterit stops and prints the answer.
Remote MCP
Section titled “Remote MCP”Agents that connect over HTTPS need no download. Give the agent the address alone and it signs you in (see Connect an app):
https://cms.example/api/manage/mcpFor automation, send a management credential instead. Settings → API shows both:
{ "mcpServers": { "dee-wan": { "type": "http", "url": "https://cms.example/api/manage/mcp", "headers": { "Authorization": "Bearer <your credential>" } } }}- One JSON-RPC message per
POST, one JSON answer. No session, no stream:GETandDELETEare405. A notification is202. A batch is refused. - Protocol versions
2025-06-18and2025-03-26. AnotherMCP-Protocol-Versionheader is400. - The credential is read again on every message. A revoked credential is
401on the next one. EachPOSTcounts once against the rate limit. tools/listlists only the tools the credential’s scopes allow. A call runs through the same scope check, audit, conflicts and idempotency as the HTTP API, as the credential’s creator.- A request from a browser page on another origin is
403. - The remote upload tool takes the file as
content_base64andfile_name, at most 8 MiB decoded. upload_import_parttakes one part ascontent_base64withidandpart.read_export_chunkandread_import_artifactanswer one byte range as{ content_base64, content_type, offset, length, total, next_offset, sha256, page, next_page }, never a whole large file;content_typeandsha256describe the whole file, andpageandnext_pagearenulloutside an export page.get_import_findingsreads one page of findings.- With
plugins:use, each active plugin view and entry action is also a tool, such asplugin_com_example_scheduler_view_calendar(no arguments) orplugin_com_example_scheduler_action_schedule(instance_id). The name isplugin_, the plugin id,vieworaction, and its id, lowercased with other characters as_. Tools are read again on every message: a disabled or removed plugin has none on the next one. A name two launches share, a name a core tool has, or a name over 64 characters is listed for none of them;GET /pluginssays why inmcp_tool.omitted_because. A view or action call only creates a launch link, as above. The stdio adapter lists the same tools. - Each operation this credential may run is a tool too, such as
plugin_com_example_scheduler_op_status. Its arguments areinstance_idfor an entry operation,input(the operation’sinput_schema) and an optionalidempotency_key. A call runs the operation now and returns its result asstructuredContentand text. Its annotations keepdestructive. A tool the credential may no longer run is gone on the next message; calling it anyway isUnknown tool. Anautomationoperation is never a tool. - Both transports give the same tool names, schemas, annotations, results and refusals. Only the upload tool differs: base64 here, a local file path in the stdio adapter.
- A credential is created by a signed-in person in Settings → API, or by connecting an app.
Connect an app
Section titled “Connect an app”An MCP client that supports OAuth signs a person in with their own account. Nothing is installed.
- The client calls the MCP address without a token and gets
401withWWW-Authenticate: Bearer error="invalid_token", resource_metadata="…". - It reads
/.well-known/oauth-protected-resource/api/manage/mcpand/.well-known/oauth-authorization-server, registers itself atPOST /api/oauth/register, and opens the admin’s/connectpage. - The person signs in if needed, picks a site, keeps or clears the permissions requested by the app, and
presses Connect. Cancel returns
access_deniedto the app. - The client exchanges the code at
POST /api/oauth/tokenand calls the MCP address with the access token.
Rules:
- OAuth 2.1 authorization code with PKCE
S256only. Public clients only: no client secret. - Redirect URIs are exact:
https;httpon127.0.0.1orlocalhostwith any port; or a desktop app’s private-use scheme (RFC 8252 7.1), such ascursor://…orcom.example.app:/cb. A private scheme is lowercase, 2 to 64 characters, and is notjavascript,data,file,vbscript,blob,about,filesystem,ftp,ws,wss,mailto,tel,sms,intent,chrome,chrome-extensionorview-source. No fragment, no user info, at most 5. The consent page shows where the app returns to. A link naming an unknown app or an unregistered redirect URI shows an error on the page without redirecting. - Codes live 60 seconds and work once. A second use revokes what the first one issued.
- Access tokens (
dwo_) live 10 minutes. Refresh tokens (dwr_) live 30 days and rotate on every use. Every refresh token works once. Presenting any replaced one, however many rotations ago, revokes the connection. Only SHA-256 digests are stored. - An app that asks for a scope this server does not know gets
invalid_scope. An app that asks for no scope is offered every scope. POST /api/oauth/revoke(RFC 7009) revokes the whole connection from either token.- A connection acts exactly like a credential its person created with the approved scopes, for
every model.
content:readis always included. The person’s current permissions are re-read on every request: deactivation, removal of site membership or an inactive site stop the connection at once. - Any member of the site may connect an app, as themselves. They can grant only what they hold.
- Settings → API → Connected apps lists connections: everyone’s with
site:settings, otherwise your own. Revoke stops it at once. “Last used” is the last token exchange. - Dynamic registration, token and revocation are rate limited per IP. Connections, codes and clients nobody uses are removed by the daily prune after 30 days.
- In production the Worker answers
<host>/api/*and<host>/.well-known/oauth-*.
Answers
Section titled “Answers”Success is { "data": … } (version list: { "data": [...], "next_cursor": … }). A refusal is
{ "error": "…", "reason": "…" } plus detail. Branch on reason; error is for a person.
| Status | Reason | Meaning |
|---|---|---|
| 400 | invalid_request |
Body, query or header does not match v1. |
| 400 | idempotency_key_required |
The command needs Idempotency-Key. |
| 401 | credential_invalid |
Missing, unknown or revoked secret. |
| 403 | creator_unauthorized |
The creator is inactive or lost the site, or the site is inactive. |
| 403 | scope_refused |
The credential lacks the scope (required names it). |
| 403 | listing_owner_restricted |
The creator may read only their own entries of the model. |
| 409 | idempotency_in_progress |
A request with the same key is still running. Retry after Retry-After. Without it the claim stays pending: check the result, then use a new key. |
| 429 | rate_limited |
Wait for Retry-After. |
| 503 | audit_unavailable |
The audit intent could not be written; nothing ran. |
| 503 | temporary_failure |
The request did not finish. error says what is known: a settings write or a publish may already be saved with its result not recorded yet. Retry a keyed command with the same key and body. |
Lifecycle and page reasons pass through unchanged, for example not_found, model_denied,
not_owner, field_denied, locale_denied, export_limited_role, forbidden, missing_permission, version_conflict, workflow_conflict,
idempotency_key_reused, scheduled_target_stale, transition_kind_mismatch, illegal_transition,
dependents_not_acknowledged, invalid_save_request, facts_conflict, no_changes, content_live,
plugin_policy_refused, plugin_policy_unavailable, plugin_policy_budget_exhausted,
plugin_policy_transform_invalid, plugin_policy_transform_conflict,
media_in_use, media_usage_unreadable, media_framing_not_image,
in_trash, not_in_trash, entry_in_trash, owner_language, restore_conflict, locale_exists,
unknown_locale, language_changed, locked, lock_lost, draft_conflict, assignee_cannot_act, due_date_past, batch_relation_terms_invalid,
batch_relation_field_invalid, batch_expected_versions_invalid, related_from_field_invalid,
related_from_source_invalid, related_from_replace_invalid and related_from_replace_unconfirmed.
field_validation_failed (422) and field_value_taken (409) also carry locale and
issues: [{ field, message, key?, params? }]. field is a full path (faqs[2].question).
message is English. key and params name the admin message the sentence was written from, so
the admin shows it in its own language. Other callers can ignore them.
Every write records an intent before it runs and a result after it in the site’s administrative
history. The exceptions are plugin launches and the editing-lease and shared-draft routes; a shared
draft is unsaved work, never a version. The actor is the creator; change.management_access names the credential. Operations:
management.content.save, management.content.transition, management.content.move,
management.content.assign, management.content.locale.add, management.content.version.restore,
management.content.trash, management.content.trash.restore, management.content.purge,
management.content.relate, management.content.related_from, management.menu.save, management.menu.language_add, management.page.save, management.page.translation.start, management.media.upload, management.role.create, management.role.update, management.role.delete, management.media.folder.create,
management.media.folder.update, management.media.folder.delete, management.media.move,
management.media.update, management.media.delete, management.import.source.create,
management.import.create, management.import.upload, management.import.advance,
management.import.mapping, management.import.assignment, management.import.pause,
management.import.resume, management.import.cancel, management.import.undo,
management.export.create, management.export.advance, management.proposal.create,
management.schema_proposal.create, management.model.apply, management.model.delete,
management.settings.update, and
management.credential.issue /
management.credential.revoke for the credential itself, and management.oauth.connect /
management.oauth.revoke for a connected app. A connected app’s writes also carry change.via,
the app’s name, so the history reads “person via app”. Versions and workflow events name the
creator, as they would for an edit in the admin. An applied proposal’s version names the person who
applied it. An applied schema proposal is an ordinary model.apply by the person who saved it, with
change.proposal naming the proposal.
Not in v1
Section titled “Not in v1”- Workflow changes and publish override.
- Secrets, sign-in methods, identity providers, deployment, source providers and domains.
- Plugin administration and plugin commands. Install, grant, disable and the plugin’s own work use the admin and the separate plugin protocol. The management API only lists plugins, creates launch links and runs declared operations.
- Generated-backend CRUD. It is never the CMS management surface.
Example
Section titled “Example”curl -s https://cms.example/api/manage/v1/entries \ -H "Authorization: Bearer $DEE_WAN_MANAGEMENT_TOKEN" \ -H "Idempotency-Key: create-hello-1" \ -H "Content-Type: application/json" \ -d '{"model_id":"<model id>","locales":{"en":{"title":"Hello"}}}'Next step
Section titled “Next step”Your website is connected. To keep it running, set up backup and restore. To extend Dee Wan, start with plugin authoring. The standalone backend stays optional.
Unreleased / pre-MVP; no stable version
Source revision: fe23db481c422b40e317b92fc98116b4c0c26a87 (modified checkout)
Build date (UTC): 2026-10-18T09:22:34.630Z
Upgrade target: unavailable: modified checkout. Release notes