Skip to content
Dee Wan 1.0 is not yet released (pre-MVP). See the 1.0 product boundary.

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.ts by npm run docs:management-api. npm run check:management-api fails when any of them is stale.

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.

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:own ownership 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 return field_denied or locale_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.

  • Base path: /api/manage/v1.
  • Send Authorization: Bearer <secret>. The site comes from the credential. A request that sends X-Site-Id or 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 with replayed: true (pages: created: false). A key reused for a different request is idempotency_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_progress with Retry-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, advanceImportUndo and retryImportUndo run again only after the first request ended with an error; when it stopped answering (a timeout, a lost connection), the claim stays pending.
  • A claim that stays pending answers 409 idempotency_in_progress without Retry-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, or unsupported_media_type for markup). Every media item includes its kind: image, document, audio, video or file. An image’s width and height come from its own header; the width and height fields are used only when the header provides neither value.
  • edited_from uploads the bytes as an edited copy of a library file (for example a crop made in another tool). The original is kept. The copy answers edited_from, takes the original’s tags, and takes its alt text and folder unless the request names them. An unknown id is 404 media_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 in MEDIA_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_id and edited_from fields, 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_duplicate decides. ask (the default) answers 409 media_duplicate with the existing file and how many copies hold it, and stores and records nothing. use_existing answers 200 with the existing file and reused: true; a retry replays it as 200. copy uploads 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, or null for Unfiled. Upload may name a folder_id; a reused duplicate stays where it is. Folders are logical: no stored file or address ever moves.
  • /media/folders answers the whole tree depth first, each folder with its revision, depth, own media_count, subtree_folder_count and subtree_media_count, plus unfiled, total and the limits: 5 levels, 500 folders per site, names of 1–80 characters unique under their parent regardless of letter case. Rename and move send expected_revision; deleting sends expected_revision, expected_folders and expected_media as read. A stale value is 409 media_folder_changed with the current folder. Other refusals: media_folder_exists (with the folder holding 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/move puts 1–200 files in one folder, or in Unfiled with folder_id: null.
  • Every media item also includes default_focal ({ x, y } fractions, or null), loading and fetch_priority. GET /media/{id} answers one item in the list’s shape.
  • GET /media/{id}/usage answers 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 (null when capped or unreadable), more and unreadable.
  • 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, null clears it; otherwise 400 media_framing_not_image), loading (eager, lazy) and fetch_priority (high, low, auto). Send at least one. The file, its dimensions and its address never change here.
  • POST /media/{id}/replace accepts a multipart file, with the same media kind as the existing file. It keeps the media id, references, folder, tags, credit and framing; omitted alt_texts, default_focal, loading and fetchPriority keep their current values. The verified replacement updates its name, type, size and dimensions. Its delivery URL may change: read the returned url, 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 same Idempotency-Key and file/fields to retry safely. The TypeScript operation is replaceMedia, the CLI command replace-media, and the MCP tool replace_media (remote MCP uses content_base64, file_name and id).
  • GET /media/{id}/originals lists current originals by language (content:read), with object id, lang, url, MIME type, byte length and dimensions. Private URLs are null.
  • PUT /media/{id}/originals/{lang} takes multipart file and expected_object_id; an empty string creates the first override, otherwise send the current object id. DELETE on the same path takes JSON expected_object_id (nonempty). Both need media:write and Idempotency-Key; stale pointers return 409 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}/replace still 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}/remove deletes a file nothing references. While anything does, it answers 409 media_in_use with the same usage fields; media_usage_unreadable when usage could not be read. Nothing is deleted then. A success answers storage_cleanup: complete, or pending when the record is gone but a stored copy could not be removed yet. MCP clients see it as destructive.
  • GET /media/sources lists the external media sources active plugins offer the site (a plugin granted media:source). GET /media/sources/{installationId}/{sourceId}/items answers one page of a source, at most 24 items, searched by q; send next_cursor back as cursor for the next page. Each item has its title, file name, type, size, a thumbnail_url on the plugin’s origin (load it with no referrer) and its attribution. Each source lists its modes: copy, reference or both. Nothing is stored by browsing.
  • POST /media/sources/{installationId}/{sourceId}/import with item_id and mode adds one item to the library. mode is required and is one of the source’s modes; Dee Wan never infers it. With copy, 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. With reference, Dee Wan fetches nothing and keeps the plugin’s delivery URL and the item’s identity: the media answers external: true, its url is the delivery URL, its bytes cannot be replaced or edited (media_external_bytes_unavailable, 409) and deleting it removes only the library row. folder_id and on_duplicate work as on upload (for reference, the same plugin, source and item is the duplicate); use_existing answers the existing file with 200. The new file answers attribution (source name, author, license, links, and the plugin, source and item ids). Every media item includes attribution, null for files added any other way. Refusals: media_source_unavailable (404), media_source_mode_unsupported (422, the source does not declare that mode), plugin_refused (422, with the plugin’s refusal), plugin_unreachable, plugin_response_invalid and media_source_file_unreachable (502), plugin_timeout (504), and the upload refusals such as media_format_not_enabled, media_kind_not_enabled and media_too_large. The audit records it as a media upload naming the source.
  • Rate limit: 120 requests per minute per credential. Over it: 429 with Retry-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: query locale and kind (content or page).
  • POST /entries/{id}/editing/takeover: body { locale }.
  • PUT /entries/{id}/editing/draft: body { locale, kind, baseline_version_id, expected_revision, data }. baseline_version_id is the version the draft starts from, or null; data is the draft’s field values.
  • POST /entries/{id}/editing/draft/discard: body { locale, kind }, and optional revision to 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_denied with locale: 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_denied with fields: ["slides"]: a page draft for a creator who cannot read the page’s sections. A creator who cannot read the page title gets title and baseline.title as ""; their writes keep the stored title.
  • 423 locked with holder and recovery: 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_conflict with current: the revision is stale. current and every other draft answer hold only the fields the creator may read. current is null when 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.

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:

  1. Upload the file in parts: PUT /imports/{id}/archive/parts/{n} with Content-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 are 409 import_part_conflict, whatever the key, and nothing of them is stored. No URL is ever fetched.
  2. POST /imports/{id}/archive/complete, optionally with client_sha256 of the whole file. Native .deewan uploads verify at most 32 MiB per call. While outcome.more is true, continue with advance or repeat completion with a fresh idempotency key. The saved checkpoint resumes by job ID. client_sha256_matches is answered only after sealing.
  3. POST /imports/{id}/advance until 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.
  4. When decision names 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 what GET /imports/{id}/findings lists. ?recheck=true checks a finished check again. A history transfer or WordPress import may also map the people it credits to members here: GET /imports/{id}/principals lists each principal (ref, source label, email, uses, suggestion, choice, mapped, problem) and the active members; PUT /imports/{id}/principals with { "ref", "user_id" } maps one (user_id: null leaves it a label). Native .deewan reviews page principals and assignments with an opaque next_cursor: pass it unchanged with limit (1–100, default 50); the active-member choice list accompanies each principals page. POST /imports/{id}/principals/accept-suggestions accepts exact-email suggestions only for the requested native-transfer page: use the same cursor and limit as its GET, then continue with the GET’s next_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 unknown ref is 404 principal_not_in_transfer. Nothing creates or changes an account. The check blocks with principal_mapping_invalid when 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 of blocking, warning and info.
  • decision: null, or { kind, message } with kind one of upload_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.
  • step and outcome: what this request ran, or null.

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.

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_concurrently with field. A change that names a setting without its revision is 400 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_invalid and site_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 is 409 stale_document.
  • A social image or logo is named by og_image_media_id / logo_media_id from 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 its digest. Without it the answer is 409 redirect_review_stale with the review.
  • Not here: secrets, passwords, sign-in methods, identity providers, plugin secrets and tokens, deployment, source providers, domains and other infrastructure.

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 conflict with its current state; one imported without consent answers consent_missing to an approval.
  • Reactions are visitor-facing and have no management write. A listed comment carries its reaction counts.

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_conflict with current_version_id. A create or update names addresses in slugs, one per language ({ "fr": "bonjour" }); a language left out keeps its address. A refused address names field: "slug" and its locale. A create in a language the site does not have is refused 400 unknown_locale with lang. A language removed while the create runs gives 409 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_conflict with revision.
  • Unpublish: to, expected_workflow_revision, expected_published_version_id, and acknowledged_dependents equal to ids from /entries/{id}/dependents when other content references the entry.
  • One language: add locale to a publish or unpublish to move that language only. to is checked against that language’s own state in workflow.locales, and an unpublish pins that language’s own published version. Without locale every language moves.
  • Page save: expected_current_version_id and expected_facts_digest from GET /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, or null for blank) and expected_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_id must 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 locked with the holder. GET /entries/{id}/editing?locale=&kind= reads who holds it and the shared draft (kind: content for the entry editor, page for the page composer); POST /entries/{id}/editing/takeover ends 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/draft names kind and expected_revision (0 when there is none); stale: 409 draft_conflict with current. POST /entries/{id}/editing/draft/discard removes it. A draft is never a version; an entry update clears it.
  • Move: to and expected_workflow_revision, optional note and locale. A step that would publish or unpublish is 422 transition_kind_mismatch: use publish or unpublish for it. A move without locale between 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, or null to clear) and optional due_date (YYYY-MM-DD, today or later). Stale: 409 workflow_conflict with revision. 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. Optional locale restores 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 needs restore for that language.
  • New language: lang from /languages, optional slug (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_exists with its instance_id.
  • Trash: scope: entry moves every language; scope: language moves one non-owner language (owner_language for the owner). Live content is refused with 409 content_live and live_languages: unpublish it first. A restored entry is a draft.
  • Restore from Trash: GET /entries/{id}/trash names conflicts (blocking) and references (links that will not work). Restore is refused with 409 restore_conflict while another item uses the address.
  • Purge: confirm repeats the entry id from the path. It deletes the item, its history and its visitor comments, and cannot be undone. GET /entries/{id}/trash names the comments it will delete in comments; the answer names those deleted in comments_deleted.
  • Relate: ids (1–25 entries), field (a many-to-many relation field they all have), connect and/or disconnect (1–10 linked entries in all, none in both) and expected_versions naming 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) or refused with reason, for example version_conflict or batch_relation_exclusive for a one-to-many field. A refusal stops nothing else. Nothing is published.
  • Related from: GET /entries/{id}/related-from lists 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, as groups by source model and field with relation_type. Each source has its display label, draft / live, version_id, can_change and readable; a source the credential may not read has no slug or label. PUT takes field_id, source_id, action (add or remove) and expected_source_version_id. Both need read on this entry; a change also needs update on 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 with 409 related_from_replace_unconfirmed, naming current and next (instance_id, slug, label; names the credential may not read are null). Send replace_target_id set to current.instance_id to confirm that exact replacement. The answer is outcome (saved or unchanged), the source’s new version_id and the updated related_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.

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 with proposal_stale and writes nothing.
  • The editor disables Apply while it has unsaved edits. Dismiss leaves the entry as it is.
  • A proposal’s source names 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 as Proposed by <plugin> · requested by <person>. Listing an entry’s proposals also includes { "kind": "ai", "id", "label" }, where id names the batch AI job. Each proposal includes revision; optional original_fields contains the readable baseline values for the proposed fields.

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.

  1. GET /models/{id} answers the whole definition and revision.
  2. Write against that revision:
  • POST /models with { "definition": { "model": …, "fields": [ … ] } } creates a model. No field ids.

  • PUT /models/{id} with expected_revision and model (name, slug, description, settings, enableI18n) changes the model and keeps its fields.

  • PUT /models/{id}/folder with { "folder": "Blog" } (1 to 40 characters, or null) puts the model in a content navigation folder or takes it out. It is display only: no expected_revision, the model revision does not move, and listModels shows it as folder. The answer is model_id, slug and folder.

  • PUT /models/{id}/definition with expected_revision and the complete definition replaces it: keep a field by its id, add one without an id, leave one out to remove it.

  • POST /models/{id}/remove with expected_revision and confirm (the model id again) deletes a model that holds no content.

  • The answer is model_id, slug, created_field_ids and renamed_from. After a rename or a delete, dependents.complete says whether all role grants and token scopes naming the old slug were updated or removed; it is absent on a replay.

  • revision changes with any change to the model or its fields. A stale one is 409 model_changed and writes nothing.

  • A change unsafe for existing content is refused with 409 model_change_blocked and changes (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_empty and model_has_bound_queries.

  • The creator must hold model:apply now, and model:delete for a delete; without it the answer is 403 missing_permission.

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.

  1. GET /models/{id} answers the model’s whole definition (model and fields, each field with its id), updated_at and revision.
  2. POST /models/proposals with 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"
}
  • definition is complete: keep each field by its id, add a field without one, leave a field out to remove it. Field order is array order. config, derived, ui and validation are 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_taken and model_name_collision. If the model changed since getModel, the answer is 409 model_changed with current_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:apply now; without it the answer is 403 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 applied in the same write. They can edit it first.
  • A proposal is pinned to base_updated_at. Once the model changes it is stale: true, its apply is refused with 409 schema_proposal_stale, and it can only be dismissed.
  • GET /models/proposals lists the site’s proposals, newest first (state filters open, applied, dismissed). GET /models/proposals/{id} reads one.

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 /plugins lists installed plugins: state (active or disabled), launchable, and for an active plugin its manage_views and content_actions. They come from the manifest stored at install or upgrade. GET /plugins/manage-views and GET /plugins/content-actions list the same entries flat. instance_id keeps 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}/launch and POST /plugins/{id}/content-actions/{action}/entries/{instanceId}/launch create one launch link. The answer is 201 with outcome: launch_created, url, expires_at and single_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:read gives content:read and workflow:read; lifecycle:publish gives workflow:publish, workflow:unpublish and workflow:locale; content:write gives content:write, content:create and draft:write; content:trash gives content:delete; media:write gives media:write, media:read, media:update and media:source; content:propose gives the same name. A plugin command outside that is capability_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, is 404 not_found.
  • No Idempotency-Key: each call makes a new link. Unused links expire.

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/operations lists the operations this credential may run now: scope (site or entry), destructive, input (the declared fields), input_schema (JSON Schema of input), result (the result components) and model_ids (for an entry operation, the models it may run on). instance_id keeps only that entry’s operations. GET /plugins carries 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 needs site: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’s releases, release-edit and release-run are such operations. A grant or permission change shows on the next request.
  • POST /plugins/{id}/operations/{operation}/invoke runs it. The body is { "instance_id"?: "…", "input"?: { … } }; instance_id is required for an entry operation and refused for a site one. input is closed: an unknown key or a value outside the field’s bounds is 400 input_invalid with issues.
  • The answer is 200 with outcome: completed and result.blocks: text, rows, links (https only) and json. The same Idempotency-Key and body return the first result with replayed: true; the key with another body is 409 idempotency_key_reused. A replay is checked like a new run first: an operation no longer offered to this credential is 404 operation_unavailable, not the old result.
  • Refusals: 404 operation_unavailable (unknown, or not offered to this credential now), 422 plugin_refused (the plugin’s own refusal: { 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.run with digests of the values and the result, not the values themselves.

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, with failure), or one that transformed the saved content (outcome: transformed, fields naming what it changed). Each names installation_id, plugin_name, policy_id, policy_label, locale, message and fields. 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. cause holds the rule’s code, message and detail. 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 names plugin, policy, policy_label, locale, the plugin’s code and message, and fields. Nothing was written.
  • 503 plugin_policy_unavailable: a policy set to block did not answer. It names the policy and the failure. 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.

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]).

Terminal window
export DEE_WAN_MANAGEMENT_TOKEN=<credential>
node dee-wan-manage.mjs help
node 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.json
node dee-wan-manage.mjs upload-media --url https://cms.example --file photo.webp
node 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 0 on success, 1 when Dee Wan refused (the answer is printed), 2 for usage, network or credential problems.
Terminal window
node dee-wan-manage.mjs login --url https://cms.example
node dee-wan-manage.mjs status --url https://cms.example
node dee-wan-manage.mjs logout --url https://cms.example

login signs you in with your own account, like Connect an app:

  1. It reads /.well-known/oauth-authorization-server. The issuer must be the origin, and the token, registration and revocation endpoints must live on it. PKCE S256 must be supported.
  2. It starts a listener on 127.0.0.1 only, on a port the system picks, and registers a client whose one redirect URI is http://127.0.0.1:<port>/callback.
  3. It opens /connect in the browser with PKCE S256 and a random state. --no-open prints 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.
  4. The callback must be GET /callback with the same state, the issuer as iss, and exactly one code. Other paths answer 404. Cancel ends the login with exit 1. After 5 minutes it gives up with exit 2.
  5. 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-wan on macOS, $XDG_CONFIG_HOME/dee-wan or ~/.config/dee-wan on Linux, %APPDATA%\dee-wan on Windows. The directory is 0700 and the file 0600 (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 2 and asks you to run login again. The file is kept.
  • A new login for the same origin replaces it and revokes the old connection.
  • DEE_WAN_MANAGEMENT_TOKEN always wins. It is never saved. status says 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:

Terminal window
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.xml
node dee-wan-manage.mjs advance-import --id <job id> --follow
node 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.json
  • upload-import-part --file sends 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 takes file_path and an optional part.
  • advance-import --follow advances 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. --finish first 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-assignments and get-import-principals --all read every page into one answer.
  • On 429, and on 409 idempotency_in_progress with Retry-After, the command waits for Retry-After and retries with the same key. Without Retry-After it stops and prints the answer.

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/mcp

For 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: GET and DELETE are 405. A notification is 202. A batch is refused.
  • Protocol versions 2025-06-18 and 2025-03-26. Another MCP-Protocol-Version header is 400.
  • The credential is read again on every message. A revoked credential is 401 on the next one. Each POST counts once against the rate limit.
  • tools/list lists 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_base64 and file_name, at most 8 MiB decoded.
  • upload_import_part takes one part as content_base64 with id and part. read_export_chunk and read_import_artifact answer one byte range as { content_base64, content_type, offset, length, total, next_offset, sha256, page, next_page }, never a whole large file; content_type and sha256 describe the whole file, and page and next_page are null outside an export page. get_import_findings reads one page of findings.
  • With plugins:use, each active plugin view and entry action is also a tool, such as plugin_com_example_scheduler_view_calendar (no arguments) or plugin_com_example_scheduler_action_schedule (instance_id). The name is plugin_, the plugin id, view or action, 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 /plugins says why in mcp_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 are instance_id for an entry operation, input (the operation’s input_schema) and an optional idempotency_key. A call runs the operation now and returns its result as structuredContent and text. Its annotations keep destructive. A tool the credential may no longer run is gone on the next message; calling it anyway is Unknown tool. An automation operation 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.

An MCP client that supports OAuth signs a person in with their own account. Nothing is installed.

  1. The client calls the MCP address without a token and gets 401 with WWW-Authenticate: Bearer error="invalid_token", resource_metadata="…".
  2. It reads /.well-known/oauth-protected-resource/api/manage/mcp and /.well-known/oauth-authorization-server, registers itself at POST /api/oauth/register, and opens the admin’s /connect page.
  3. The person signs in if needed, picks a site, keeps or clears the permissions requested by the app, and presses Connect. Cancel returns access_denied to the app.
  4. The client exchanges the code at POST /api/oauth/token and calls the MCP address with the access token.

Rules:

  • OAuth 2.1 authorization code with PKCE S256 only. Public clients only: no client secret.
  • Redirect URIs are exact: https; http on 127.0.0.1 or localhost with any port; or a desktop app’s private-use scheme (RFC 8252 7.1), such as cursor://… or com.example.app:/cb. A private scheme is lowercase, 2 to 64 characters, and is not javascript, data, file, vbscript, blob, about, filesystem, ftp, ws, wss, mailto, tel, sms, intent, chrome, chrome-extension or view-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:read is 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-*.

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.

  • 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.
Terminal window
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"}}}'

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