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

Protocol v1

Every route, header, body, outcome code and retry rule of the remote plugin protocol. A plugin is a separately deployed service: it speaks HTTP to Dee Wan and runs no code inside it. See also Writing a plugin for a first plugin end to end, Plugin security for the trust boundary, and Testing a plugin for how to test one.

Supported protocol versions: 1. The version is negotiated once, at install, and pinned on the installation. An upgrade that would change it is refused (protocol_changed): moving an installation to v2 means uninstalling it and installing the new release. A breaking change to a route, a meaning or an error code requires protocol v2. Additive response fields are permitted only because clients are required to ignore response keys they do not know. Request shapes are closed — an unknown key is refused, never ignored.

Every plugin route takes Authorization: Bearer <token>, where the token is dwp_ followed by 43 URL-safe characters of 256-bit randomness. Core stores only its SHA-256 hash and can never show it again. The plugin receives it once, at activation.

The routes live under /api/plugin/v1. They need no Origin header and no cookie, because a server sends neither. Core resolves the installation and the site BEFORE any request body is read, in this order: refuse a caller-named site or database, resolve the token to one active installation, spend the rate limit, resolve the site, route to the tenant database.

Consequences worth stating plainly:

  • The site is derived from the token. A plugin cannot name one, and sending X-Site-Id at all is invalid_request.
  • A user cookie is not plugin authentication, and a plugin token is not accepted on any administrative route.
  • pending, disabled and revoked installations answer plugin_unauthorized before any tenant access happens. So does every installation on a site that is not active.
Header Where Meaning
Authorization: Bearer <token> every plugin request This required header contains the installation token.
Idempotency-Key transitions, writes, drafts, media and proposals; future-version preparation uses its installation/release/entry identity instead This header is required. Use 1 to 128 characters in \x21–\x7e (printable ASCII, no space). Core keeps the idempotency record for 30 days. Do not use this header with /launch/exchange.
Content-Type: application/json requests with a body Requests with a body must use this format.
X-Site-Id never Core refuses this header. The site comes from the token.
Retry-After on a retryable response Core emits seconds on 429. Clients also accept HTTP dates on inbound retryable statuses. Honour it within attempt, age and wait caps.
Referrer-Policy: no-referrer core’s launch response, and your management page Core sets it only on its own launch response. The browser then opens your management_url with dee_wan_launch in the query. So your page must send Referrer-Policy: no-referrer itself and remove the code from the address bar. Exchange it server-side, then redirect to the same URL without dee_wan_launch.
Limit Value
Request body 16384 bytes; over a limit is limit_exceeded
Field write or proposal body 81920 bytes, 50 fields, 65536 bytes of values
Draft body 1916384 bytes; a draft 1900000 bytes, 200 per person
Media body 33619968 bytes; a file 33554432 bytes
Manifest body 32768 bytes
Idempotency key 128 characters
Content page 50 items, default 20
Delegation entries 100 entry ids per read
Requests per installation 120 per 60s window, keyed by installation
Launch code lifetime 60s, single use, stored hashed
Token rotation overlap at most 10 minutes, ended by the first request with the new token
Idempotency record 30 days. Afterwards the same key is a new command
Delegation lifetime no time limit. See Delegations
Invocation 10s for the POST, the exchange and the answer together; the code expires with it
Invocation answer 65536 bytes
Invocation input 32768 bytes of operation values or policy facts
Operation replay 24 hours for one Idempotency-Key of one caller
Capability What it permits
content:read The content read routes, within the granted models.
workflow:read The workflow block on a read: state, revision and the boundary transitions. Commands must pin workflow.revision, so a plugin that sends them needs this.
workflow:publish A kind: publish command.
workflow:unpublish A kind: unpublish command.
workflow:locale A command that names one locale and moves that language alone, and the workflow.locales list on a read. Without it a command that names a locale is capability_refused; a command without locale always moves the whole translation group.
workflow:release Prepare and read installation-bound future versions, or submit one bounded same-site final-state publish/unpublish command with one atomic tenant commit.
content:write Saving value fields of an item as the delegating person, pinned to its current version.
content:create Create entries as the delegating person, within reviewed model grants.
content:delete Move entries to Trash as the delegating person, within reviewed model grants.
draft:write Reading, writing and discarding the delegating person’s own working drafts.
media:write Upload files within the person’s current media grants.
media:read Read existing media metadata and bytes within the person’s current media grants.
media:update Edit existing media metadata within the person’s current media grants.
media:delete Delete eligible unused media with current media:delete permission and retained-reference checks.
content:restore Restore a Trash item through the current actor’s delete and language grants.
comments:read Read visitor comments with the current actor’s moderation authority and granted model visibility.
comments:moderate Moderate visitor comments through the existing consent and state-conflict checks.
redirects:read Read redirects with current site:settings permission.
redirects:write Review and change redirects through the existing Site Chrome revision and pattern checks.
mail:send Send administrator-approved templates to fixed addresses, the entry author or assigned reviewer, with current site:plugins permission and bounded declared substitutions.
renderer:metadata Save declared per-language metadata contributions as a new draft version; core SEO and verification values win.
content:propose Proposing value fields for an editor to review, and reading this installation’s proposals.
media:source Answering the manifest’s media_sources: people browse them inside the media picker and copy a chosen file into the site’s own library, or, for a source that declares the reference mode, link it. It reads nothing from core.

A grant is the intersection of these with a per-site list of content-model ids. An empty model list means no content access — not every model. Capabilities outside the stored manifest request cannot be granted, so a plugin update can never widen an existing grant.

pending, active, disabled, revoked. Only active may call the plugin API. revoked is terminal: its token never becomes valid again, and uninstall revokes before anything else.

On install (kind: "activate") and on token rotation (kind: "rotate") core POSTs $defs/activation_handoff as JSON to the manifest’s activation_url. It carries the raw token and the activation_code the administrator pasted into Dee Wan. The plugin mints that code, one per install or rotation, and must check it. Core accepts only codes matching ^[A-Za-z0-9_-]{16,128}$. Any 2xx accepts the token. Any other status, a redirect, or no answer within 5s refuses it.

The token is not valid while core waits for that answer: the installation stays pending until core receives your 2xx. A call to /api/plugin/v1 made from inside the handler answers plugin_unauthorized. Store the token, answer 2xx, then call /context. A rotation works the same way: the new token is valid only after your 2xx.

A person opens the plugin from Dee Wan. The plugin exchanges the launch code at /launch/exchange and receives a delegation_id. The exchange of an entry operation returns one too, bound to that entry. Every command names one, and reads may name one. It says who asked. It adds no capability beyond the installation grant.

  • No time limit. A delegation does not expire with time, so a schedule can use it days or weeks later.
  • It stops working when its person is deactivated or is no longer a member of the site (requester_unauthorized), and for good when the installation is uninstalled, which revokes its token (plugin_unauthorized on every request). While the installation is disabled or the site is inactive, every request answers plugin_unauthorized too.
  • Authority is re-read on every request. A command is refused requester_unauthorized when the person may no longer take that transition. A read answers transitions: [] instead.
  • Content-action delegations (instance_id is set) speak only for the translation group of the item they were launched on: that item, its translations and the group primary. Any other item, or a launched item that no longer exists, is requester_unauthorized.
  • manage delegations (instance_id is null) speak for items of the models in their model_ids: the granted models their person could read at launch, every entry or only their own.
  • Models are re-checked on every request. An item whose model is not in model_ids, or that the person may no longer read, is requester_unauthorized. /delegations/:delegationId lists what a delegation reaches now.
  • Own entries are checked one by one. Under read:own or update:own a model comes back entries: "checked". /delegations/:delegationId/entries says which of the entries you hold the person may read now and what they may do there. Show nothing it leaves out.
  • A credential caps it. A delegation a management credential opened carries only the installation capabilities that credential’s scopes allow. A command outside them is capability_refused.
  • The model grant is re-checked on every request. An item outside the current grant is content_not_found, whatever the delegation.
  • action comes back exactly as launched. Core gives it no meaning beyond manage.

A manifest may declare operations (at most 8) and policies (at most 8). Core runs them on the plugin. It renders every form and result with its own components; nothing a plugin sends is markup or code.

  • An operation has a closed input of at most 8 fields — text (with max_length), number (min, max, integer), boolean, select (closed options) — and a result list of CMS components: text, rows (key/value), links (https only) and json (offered as a download). scope: "site" runs from Settings → Plugins and needs site:plugins. scope: "entry" runs on one entry of a granted model the person reads; with destructive: true the person must also be able to update it and confirms first. audience is required. interactive operations are listed and run in the admin, the management API, the CLI and MCP. automation operations never appear there and those run routes refuse them; only a named core workflow (the content planner’s Scheduling handoff) invokes one, through the same transport, live authority, grants, idempotency and audit.
  • A policy declares one kind: save (every content save, including one that changes only links, media or search settings), restore (an old version restored, or an entry leaving Trash), delete (an entry or a language moved to Trash, or deleted permanently), publish, unpublish, and transition (every workflow move, scheduled and plugin moves included). Its bound input is $defs/policy_facts: the kind, the model id and slug, the entry id (null for a new entry), the locale, changed_fields, values, and where they apply links, media, seo, action and transition. For save and restore they hold the candidate’s changed parts; for the other kinds the whole version in question. Links every language shares and media ride on the primary language only. No credential and no other content. It answers $defs/policy_reply: allow, warning with a message, refuse with a code and a message, or — for save and restore only — transform with replacement values, links, media or seo. fields name field keys.
  • An operation may instead be a content-list column or a list action. A column is scope: "page" with audience: "column", a cell kind (text, badge or link), result: ["cells"], no input and not destructive. For one granted model it receives $defs/column_request — the model and at most 50 row ids the person may read, never values — and answers one cells block: at most one cell per named row, each { id, text, tone?, url? } (tone for a badge, an https url for a link, text up to 80). It reads what it needs through its own grant. A column binds no delegation and is never listed or run as an ordinary operation. A list action is an interactive entry operation with list_action: true: Core runs it once for each of at most 25 rows a person selects, with that person’s live authority checked per row.
  • requires lists installation capabilities from requested_capabilities. While the grant lacks one the item is unavailable. An accepted manifest update never widens a grant, so a new item that needs more is unavailable until an administrator grants it.

One invocation, in order:

  1. Core records the invocation — installation, site, caller, declared id, optional model and entry, input digest, expiry — and the SHA-256 of a one-time dwi_ code.
  2. Core POSTs $defs/invocation_envelope to the declared endpoint: protocol version, installation id, invocation id, code, expiry. It carries no content and no credential. Redirects are refused.
  3. The plugin sends the code to POST /api/plugin/v1/invocations/exchange with its bearer token and gets the bound input ($defs/invocation_binding). Core consumes the code once.
  4. The plugin answers core’s POST with HTTP 200 and $defs/operation_reply or $defs/policy_reply.

Core accepts the answer only when this installation exchanged the invocation and still covers it: active, the same accepted version and declaration, every required capability and the model still granted. Core classifies everything else and stores digests, never the bodies, in the audit ledger:

Failure When
plugin_timeout no complete answer within 10s
plugin_unreachable no connection, a redirect, or a 5xx
plugin_response_invalid any other status, not the reply schema, a result component not declared, too large, or answered without the exchange
plugin_refused an operation answered status: "refused" with its own code and message
plugin_unavailable the installation was disabled, revoked, narrowed or changed while the invocation ran

Those are all of them: plugin_timeout, plugin_unreachable, plugin_response_invalid, plugin_refused, plugin_unavailable. Never answer before exchanging, and never cache a binding: every invocation has its own code.

  • Core asks after its own checks (fields, rules, permissions, workflow, publication) and before it commits. A policy never replaces them, and it never moves a workflow.
  • It asks the active installations whose grant holds the entry’s model and whose policy is available, all at once, at most 8 at a time. A save or a restore asks once per language whose values, links, media or search settings change; a publication, unpublication, deletion or move once per language it affects. A restore asks save and restore policies.
  • transform replaces parts of the entry and language you were asked about, by field key, and nothing else: it names no entry, so it cannot reach another one. values and per-language links land in that language; links every language shares and media only from the primary language; seo replaces that language’s search settings (the editor’s shape, image as { media_id, alt }). A list replaces the whole list. A restore takes values and seo only. Core then runs the whole write again with your parts in place, through every rule user input meets (field rules, rich text, relations, media, uniqueness, permissions). If core rejects any part, it refuses the write as plugin_policy_transform_invalid with the cause; two policies that change one part differently refuse it as plugin_policy_transform_conflict. Your policy is not asked again on that second pass. The result lists outcome: "transformed" with the fields you changed and your optional message, and the stored version records it in meta.policy_transforms. A transform that changes nothing is an allow. A transform to any other kind is an answer core does not accept.
  • refuse always refuses the write. The editor names your plugin and policy and shows your message on the fields you name. warning lets the write through and shows the message. Nobody submits twice.
  • A timeout, an outage or an answer core does not accept follows the mode an administrator chose for the policy in Settings → Plugins: warn (the default) lets the write through with a warning, required refuses it.
  • caller.via is person, management_credential, planner when the content planner works for its plan owner, or plugin when another plugin’s delegated write is being checked. An import, a spreadsheet and a restore are asked as the person who runs them.
  • The write is committed only if what you were shown is still current. A change in between is the ordinary version conflict, and your answer is not applied to other content.

A manifest may declare hooks: at most one per kind, upload_metadata and comment_moderation. Each has an id, a label, a same-origin HTTPS endpoint, and requires (capabilities listed in requested_capabilities). A hook is off until an administrator turns it on for the installation in Settings → Plugins. Core asks an enabled hook BEFORE the operation, not after: upload_metadata before a new library file is stored, comment_moderation before a visitor comment is stored.

  • A hook is called through the same invocation transport, with kind: "hook" in the binding, caller: null and no delegation. Core tells a hook what it is asked and never who asked: no person, email, credential, file bytes or other entry.
  • upload_metadata: the bound input is $defs/hook_upload_facts (file name, MIME type, size, folder and alt texts; width and height are null, not measured yet). Answer $defs/hook_upload_reply: accept, optionally with normalized (a new filename that keeps the extension, and alt texts only in languages the upload already named), or refuse with a code and a message the uploader reads (422 upload_hook_refused).
  • comment_moderation: the bound input is $defs/hook_comment_facts (the entry, the comment’s id, parent, author name, website and body; never the email). Answer $defs/hook_comment_reply: approve, hold (stays pending) or reject (stored as spam, or as refused with as: "refused").
  • A hook that cannot answer never approves. A timeout (5 seconds), an outage, a body that is not one of the closed replies, or an installation that was disabled or changed while it ran refuses the upload (503 upload_hook_unavailable, nothing stored) or leaves the comment pending for moderation.
  • The retry identity is a digest of the question: a retried upload or a re-sent comment replays the first ACCEPTED answer for 24 hours instead of asking again. A failed question is not replayed; the next identical one asks again.

A manifest may declare media_sources (at most 4) and request media:source. Each source is a library people browse inside the media picker, next to the site’s own library, and through the management API. Each source declares modes: copy (core copies the chosen file into the site, so published content never depends on the source), reference (core keeps only the file’s delivery URL and identity and the source keeps the bytes), or both. The person or the API caller names the mode on every add; core never infers it from a URL or a name.

  • A source is called through the same invocation transport, with kind: "media_source" in the binding and no delegation. Its bound input is $defs/media_source_request: browse with query (empty for the first page), cursor (the previous next_cursor, or null) and limit (at most 24), or item with the item_id a person chose.
  • Answer browse with $defs/media_source_page_reply and item with $defs/media_source_item_reply, or status: "refused" with a code and a message. Each item carries a title, a file name, its MIME type, optional size and dimensions, a thumbnail_url, a download_url (required for copy), a delivery_url (required for reference) and an attribution: source_name, license, and optional author, author_url, license_url and source_url. An image’s description (at most 125 characters) becomes its alt text in the site’s default language.
  • thumbnail_url and download_url must be on the manifest’s one origin. A delivery_url must be on one of the source’s delivery_origins (at most 4 bare origins, required with reference and absent without it; the manifest origin may be one of them). An answer naming any other origin is plugin_response_invalid. The picker loads thumbnails straight from your origin with no referrer and no Dee Wan credential. Attribution links are shown, never fetched.
  • When a person chooses an item, core asks item again, then GETs download_url with no credential and no redirect, within 20s and the media file limit. The bytes go through the same checks as an upload: the site’s kinds, formats and size limits, and its duplicate check. The new media keeps the attribution and the plugin, source and item ids. Sign download_url yourself if your files are not public.
  • In reference mode core fetches nothing. The media row keeps your delivery_url as its address, your plugin, source and item ids as its identity and the metadata you declared; the same item added twice follows on_duplicate. Published pages, site copies and generated sites use the delivery_url as it is, so it must stay served for as long as sites use it. Core never proxies it, replaces its bytes, or calls you when the file is deleted from the library. A mode the source does not declare is refused with media_source_mode_unsupported.
  • Browsing changes nothing, so it is not audited. The copy is an ordinary media upload by the person who chose it.
  • The caller needs media:upload. A disabled installation, a narrowed grant without media:source, or a changed declaration makes the source unavailable at once.

What this installation is. The installation id, the site it is bound to, the accepted protocol version and the grant. Read it at start-up to learn what this installation may ask for. It names no user, no token hash, no database and no infrastructure.

  • Caller: the plugin, with its installation token
  • Requires: none
  • No query, no body.
  • Answers: { "data": <context> } — $defs/context.

Outcomes:

  • plugin_unauthorized
  • rate_limited
  • temporary_failure

What this installation may do

Terminal window
curl "https://cms.example/api/plugin/v1/context" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp"
{
"data": {
"installation_id": "plg_0000000000000000000000000000d1ee",
"site": {
"id": "cdocssite00000000000000001",
"name": "docs-site",
"default_lang": "en"
},
"protocol_version": 1,
"capabilities": [
"content:read",
"workflow:read",
"workflow:publish",
"workflow:unpublish"
],
"model_ids": [
"cdocsmodel0000000000000001"
]
}
}

An unknown, disabled or revoked token

Terminal window
curl "https://cms.example/api/plugin/v1/context" \
-H "Authorization: Bearer dwp_UnknownTokenNotRealUnknownTokenNotRealUnkno"
{
"outcome": {
"code": "plugin_unauthorized",
"message": "The installation token is missing, unknown, disabled or revoked.",
"retry": false
}
}

One answer for all four. Nothing tells a caller which of them applies.

A plugin cannot name a site

Terminal window
curl "https://cms.example/api/plugin/v1/context" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp" \
-H "X-Site-Id: cotherdocsite00000000001"
{
"outcome": {
"code": "invalid_request",
"message": "A plugin request cannot name a site.",
"retry": false
}
}

The site comes from the token. Sending the header at all is refused, even with the right value.

An unknown plugin route

Terminal window
curl "https://cms.example/api/plugin/v1/schedules" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp"
{
"outcome": {
"code": "content_not_found",
"message": "No such plugin route.",
"retry": false
}
}

Protocol v1 exposes only the documented routes. A 404 here is not a temporary failure.

List content in one granted model. One page of primary instances in one model. model_id is mandatory: there is no “all models” read. The filter set is closed — any other query parameter is invalid_request rather than ignored. The page is a narrow summary, not a content snapshot. Only translation-group primaries appear. The group is the unit a transition moves.

  • Caller: the plugin, content:read
  • Requires: content:read
  • model_id (required) — a model id from the grant.
  • published — true or false.
  • after — the previous page’s last instance_id.
  • limit — 1..50, default 20.
  • Answers: { "data": [<summary>], "next": <instance_id|null> } — $defs/content_page.

Outcomes:

  • capability_refused — no content:read, or a model outside the grant
  • invalid_request — missing model_id, an unknown filter, a limit out of range
  • plugin_unauthorized
  • rate_limited
  • temporary_failure

One page of one model

Terminal window
curl "https://cms.example/api/plugin/v1/content?model_id=cdocsmodel0000000000000001&published=false&limit=1" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp"
{
"data": [
{
"instance_id": "cdocsinstance00000000000001",
"primary_instance_id": "cdocsinstance00000000000001",
"translation_group_id": "cdocsgroup0000000000000001",
"model_id": "cdocsmodel0000000000000001",
"model_slug": "article",
"lang": "en",
"label": "Hello",
"slug": "hello",
"current_version_id": "cdocsversion00000000000001",
"published_version_id": null
}
],
"next": null
}

A filter outside model_id, published, after, limit is invalid_request, not ignored.

The filter set is closed

Terminal window
curl "https://cms.example/api/plugin/v1/content?model_id=cdocsmodel0000000000000001&sort=title" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp"
{
"outcome": {
"code": "invalid_request",
"message": "model_id is required; filters are model_id, published, after, limit.",
"retry": false
}
}

Read one item, and what it may do next. Ids, label (the entry’s title in its own language, else the current version’s note), slug, and the current and published version ids. With workflow:read, the read adds the workflow state, its revision and the transitions that cross the publication boundary. Transitions are narrowed to the capabilities this installation holds. Pass delegation_id and they are narrowed again to what that person may do NOW, re-read on every request. If the person is deactivated or no longer a member of the site, the request returns requester_unauthorized. If the person is still a member but may no longer update this item, the request returns 200 with transitions: [] (a command would still be refused requester_unauthorized). A content-action delegation is accepted only for items in the translation group it was launched on. A manage delegation is accepted for any item in the models it was launched with. Every delegation is also refused for an item whose model is not in its model_ids, or that its person may no longer read. Without workflow:read there is no workflow block. A valid delegation adds a projected saved snapshot. workflow.revision is what every command must pin, so a plugin that sends commands needs workflow:read. An item that has never moved through its workflow has revision 0. An item outside the site or the model grant is content_not_found, which is also what a cross-site id answers.

  • Caller: the plugin, content:read
  • Requires: content:read, plus workflow:read for the workflow block
  • delegation_id — a delegation from /launch/exchange or from an entry-operation binding. No other query parameter is accepted.
  • Answers: { "data": <summary> } — $defs/content_summary.

Outcomes:

  • capability_refused
  • content_not_found
  • requester_unauthorized — the named delegation is unknown, belongs to another installation, is bound to another translation group or to an item that no longer exists. This also happens when its person is deactivated or no longer a member of the site, when the item’s model is not in the delegation’s model_ids, or when its person may no longer read the item
  • invalid_request
  • plugin_unauthorized
  • rate_limited
  • temporary_failure

One item, with the transitions the requester may take now

Terminal window
curl "https://cms.example/api/plugin/v1/content/cdocsinstance00000000000001?delegation_id=pld_0000000000000000000000000000d1ee" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp"
{
"data": {
"instance_id": "cdocsinstance00000000000001",
"primary_instance_id": "cdocsinstance00000000000001",
"translation_group_id": "cdocsgroup0000000000000001",
"model_id": "cdocsmodel0000000000000001",
"model_slug": "article",
"lang": "en",
"label": "Hello",
"slug": "hello",
"current_version_id": "cdocsversion00000000000001",
"published_version_id": null,
"workflow": {
"state": "draft",
"revision": 0,
"transitions": [
{
"to": "published",
"kind": "publish"
}
]
}
}
}

Pin what this answers: current_version_id for a publish, published_version_id for an unpublish, and workflow.revision for both. A delegated read also returns saved; this example shows the summary keys.

POST /api/plugin/v1/content/:instanceId/transitions/:targetState

Section titled “POST /api/plugin/v1/content/:instanceId/transitions/:targetState”

Publish or unpublish, conditionally. This route only publishes or unpublishes content. It is conditional in four ways at once, all checked inside the same atomic lifecycle mutation. The pinned version must still be the one the transition would move. Ordinary publication pins the current ordinary draft. A future publication names both future_draft_id and expected_future_revision, pins that branch’s current saved version, and leaves the ordinary draft intact. Only a currently authorized future command may republish an already-published workflow stage. The workflow revision must still be the one the schedule was made against. targetState must be a transition available from the current state, and that transition’s publication meaning must match kind. The delegation must cover the item (see Delegations), and the delegating person’s authority is re-read before the write. A newer draft is never published silently. A changed workflow is never traversed silently. Publication applies to the whole translation group, which is why the result names the primary instance. With workflow:locale, a body that names locale moves that one language: targetState is checked against that language’s own state in workflow.locales, an unpublish pins that language’s own published version, and the result names the locale it moved. A transition that publishes also reports redirect: the automatic redirect the same lifecycle call made for a changed address, or why it made none. An exact replay answers the whole first result, locale and redirect included. Core writes the redirect after the transition commits. Until its outcome is recorded, the command and any replay of it answer temporary_failure with a message saying the transition was applied. Retry with the same key and body: the replay then answers the whole result.

  • Caller: the plugin, workflow:publish or workflow:unpublish
  • Requires: workflow:publish for kind: publish, workflow:unpublish for kind: unpublish
  • Idempotency-Key (required) — 1 to 128 characters in \x21–\x7e (printable ASCII, no space). Derive it from the schedule, not from the clock: an exact replay returns the first result, and the same key with different input is idempotency_key_reused. Core keeps the record 30 days. After that the same key is treated as a new command.
  • Body — $defs/publish_command or $defs/unpublish_command. The shape is CLOSED: an unknown key, or the other kind’s pin, is invalid_request.
  • Answers: { "outcome": {...}, "data": <transition_result> } — $defs/transition_result.

Outcomes:

  • transition_applied / idempotent_replay
  • invalid_request
  • capability_refused
  • requester_unauthorized — the delegation is unknown, does not cover this item, or its person no longer holds the authority the transition needs
  • content_not_found
  • scheduled_target_stale
  • workflow_conflict
  • dependent_content
  • idempotency_key_reused
  • transition_unavailable / transition_refused
  • rate_limited
  • temporary_failure

Publish the pinned draft

Terminal window
curl -X POST "https://cms.example/api/plugin/v1/content/cdocsinstance00000000000001/transitions/published" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: sched:plg_0000000000000000000000000000d1ee:sch_0001:r1" \
--data '{
"kind": "publish",
"expected_current_version_id": "cdocsversion00000000000001",
"expected_workflow_revision": 0,
"delegation_id": "pld_0000000000000000000000000000d1ee"
}'
{
"outcome": {
"code": "transition_applied",
"message": "The transition was applied.",
"retry": false
},
"data": {
"instance_id": "cdocsinstance00000000000001",
"primary_instance_id": "cdocsinstance00000000000001",
"from": "draft",
"to": "published",
"published": true,
"published_changed": true,
"published_version_id": "cdocsversion00000000000001",
"revision": 1,
"redirect": {
"status": "none",
"reason": "no_public_address"
},
"policy_results": []
}
}

The same command again

Terminal window
curl -X POST "https://cms.example/api/plugin/v1/content/cdocsinstance00000000000001/transitions/published" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: sched:plg_0000000000000000000000000000d1ee:sch_0001:r1" \
--data '{
"kind": "publish",
"expected_current_version_id": "cdocsversion00000000000001",
"expected_workflow_revision": 0,
"delegation_id": "pld_0000000000000000000000000000d1ee"
}'
{
"outcome": {
"code": "idempotent_replay",
"message": "This command was already applied. The first result is returned.",
"retry": false
},
"data": {
"instance_id": "cdocsinstance00000000000001",
"primary_instance_id": "cdocsinstance00000000000001",
"from": "draft",
"to": "published",
"published": true,
"published_changed": true,
"published_version_id": "cdocsversion00000000000001",
"revision": 1,
"redirect": {
"status": "none",
"reason": "no_public_address"
},
"policy_results": []
}
}

A retry after a network failure is safe: the whole first result comes back, redirect included, and nothing runs twice.

The same key, a different command

Terminal window
curl -X POST "https://cms.example/api/plugin/v1/content/cdocsinstance00000000000001/transitions/published" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: sched:plg_0000000000000000000000000000d1ee:sch_0001:r1" \
--data '{
"kind": "publish",
"expected_current_version_id": "cdocsversion00000000000001",
"expected_workflow_revision": 7,
"delegation_id": "pld_0000000000000000000000000000d1ee"
}'
{
"outcome": {
"code": "idempotency_key_reused",
"message": "This idempotency key was already used for a different command.",
"retry": false
}
}

Derive the key from the schedule and its revision. Rescheduling is a new revision, so a new key.

Unpublish the pinned live version

Terminal window
curl -X POST "https://cms.example/api/plugin/v1/content/cdocsinstance00000000000001/transitions/draft" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: sched:plg_0000000000000000000000000000d1ee:sch_0002:r1" \
--data '{
"kind": "unpublish",
"expected_published_version_id": "cdocsversion00000000000001",
"expected_workflow_revision": 1,
"delegation_id": "pld_0000000000000000000000000000d1ee"
}'
{
"outcome": {
"code": "transition_applied",
"message": "The transition was applied.",
"retry": false
},
"data": {
"instance_id": "cdocsinstance00000000000001",
"primary_instance_id": "cdocsinstance00000000000001",
"from": "published",
"to": "draft",
"published": false,
"published_changed": true,
"published_version_id": null,
"revision": 2,
"policy_results": []
}
}

A newer draft is never published silently

Terminal window
curl -X POST "https://cms.example/api/plugin/v1/content/cdocsinstance00000000000001/transitions/published" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: sched:plg_0000000000000000000000000000d1ee:sch_0003:r1" \
--data '{
"kind": "publish",
"expected_current_version_id": "cdocsversion00000000000001",
"expected_workflow_revision": 2,
"delegation_id": "pld_0000000000000000000000000000d1ee"
}'
{
"outcome": {
"code": "scheduled_target_stale",
"message": "The pinned version is no longer the one this transition would move.",
"retry": false
}
}

Somebody saved a newer draft after the schedule was made. Terminal: a person decides what to do.

A changed workflow is never traversed silently

Terminal window
curl -X POST "https://cms.example/api/plugin/v1/content/cdocsinstance00000000000001/transitions/published" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: sched:plg_0000000000000000000000000000d1ee:sch_0004:r1" \
--data '{
"kind": "publish",
"expected_current_version_id": "cdocsversion00000000000002",
"expected_workflow_revision": 0,
"delegation_id": "pld_0000000000000000000000000000d1ee"
}'
{
"outcome": {
"code": "workflow_conflict",
"message": "The content moved through its workflow after this was requested.",
"retry": false
}
}

The request shape is closed

Terminal window
curl -X POST "https://cms.example/api/plugin/v1/content/cdocsinstance00000000000001/transitions/published" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: sched:plg_0000000000000000000000000000d1ee:sch_0005:r1" \
--data '{
"kind": "publish",
"expected_current_version_id": "cdocsversion00000000000002",
"expected_workflow_revision": 2,
"delegation_id": "pld_0000000000000000000000000000d1ee",
"override_dependents": true
}'
{
"outcome": {
"code": "invalid_request",
"message": "The request is not valid for protocol v1.",
"retry": false
}
}

There is no override in protocol v1. An unknown key is refused rather than dropped.

A mutation without an idempotency key

Terminal window
curl -X POST "https://cms.example/api/plugin/v1/content/cdocsinstance00000000000001/transitions/published" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp" \
-H "Content-Type: application/json" \
--data '{
"kind": "publish",
"expected_current_version_id": "cdocsversion00000000000002",
"expected_workflow_revision": 2,
"delegation_id": "pld_0000000000000000000000000000d1ee"
}'
{
"outcome": {
"code": "invalid_request",
"message": "Idempotency-Key is required.",
"retry": false
}
}

Save field values, as the person. An ordinary lifecycle save of named value fields of one item, made AS the delegating person: their model permission, ownership and current account are re-read, and the save is theirs in the content history. It is pinned: expected_version_id must still be the current version, or the answer is version_conflict and nothing is written. The same rules the editor applies decide the values — field types, uniqueness, hierarchy, the language, the version size. fields accepts values for up to 50 value fields, with at most 65536 bytes of values in total. Use the separate relations, locale_relations and media properties for typed reference writes; fields may be empty when one of those changes references. Without locale the values are for the addressed item’s own language. A translatable slug field renames that language’s address only; a shared one names the primary language’s. slugs in the answer lists every language’s address. The administrative history records the person, with this installation named in it.

  • Caller: the plugin, content:write
  • Requires: content:write
  • Idempotency-Key (required) — as for transitions. A replay answers the first result.
  • Body — $defs/content_write_command. Closed.
  • Answers: { "outcome": {...}, "data": <save_result> } — $defs/content_save_result.

Outcomes:

  • content_saved / idempotent_replay
  • invalid_request
  • capability_refused
  • requester_unauthorized — the delegation does not cover this item, or its person may not update it now
  • content_not_found
  • version_conflict
  • validation_failed — data names the rule’s reason and, when it has them, the fields
  • limit_exceeded
  • idempotency_key_reused
  • rate_limited
  • temporary_failure

Save one field, pinned to the current version

Terminal window
curl -X PUT "https://cms.example/api/plugin/v1/content/cdocsinstance00000000000001" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: write:plg_0000000000000000000000000000d1ee:0001" \
--data '{
"delegation_id": "pld_0000000000000000000000000000d1ee",
"expected_version_id": "cdocsversion00000000000002",
"fields": {
"title": "Hello from the plugin"
}
}'
{
"outcome": {
"code": "content_saved",
"message": "The field values were saved as a new version.",
"retry": false
},
"data": {
"instance_id": "cdocsinstance00000000000001",
"primary_instance_id": "cdocsinstance00000000000001",
"translation_group_id": "cdocsgroup0000000000000001",
"version": {
"id": "cdocsversion00000000000003",
"version_number": 3
},
"current_version_id": "cdocsversion00000000000003",
"published_version_id": null,
"slugs": {
"en": "hello",
"fr": "hello"
},
"locales": {
"en": "cdocsinstance00000000000001",
"fr": "cdocsinstancefr000000000001"
},
"warnings": [],
"policy_results": []
}
}

The new version is the delegating person’s. Pin current_version_id for the next write.

A write pinned to an older version

Terminal window
curl -X PUT "https://cms.example/api/plugin/v1/content/cdocsinstance00000000000001" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: write:plg_0000000000000000000000000000d1ee:0002" \
--data '{
"delegation_id": "pld_0000000000000000000000000000d1ee",
"expected_version_id": "cdocsversion00000000000001",
"fields": {
"title": "Lost update"
}
}'
{
"outcome": {
"code": "version_conflict",
"message": "The entry changed after the pinned version. Read it again before writing.",
"retry": false
}
}

Nothing was written. Read the item again and decide with the person.

GET /api/plugin/v1/content/:instanceId/proposals

Section titled “GET /api/plugin/v1/content/:instanceId/proposals”

This installation’s proposals for one item. The proposals this installation made for the item, newest first, at most 50, in every state: open, applied or dismissed. Another installation’s or a management credential’s proposals are not listed.

  • Caller: the plugin, content:propose
  • Requires: content:propose
  • No query, no body.
  • Answers: { "data": [<proposal>] } — $defs/proposal_list.

Outcomes:

  • capability_refused
  • content_not_found
  • invalid_request
  • plugin_unauthorized
  • rate_limited
  • temporary_failure

POST /api/plugin/v1/content/:instanceId/proposals

Section titled “POST /api/plugin/v1/content/:instanceId/proposals”

Propose field values for an editor to review. Records proposed values of named value fields, pinned to base_version_id, for the item’s editor to review. THE CONTENT DOES NOT CHANGE until a person applies the proposal in Dee Wan: Apply is that person’s ordinary save, with their permissions, against the pinned version. The editor shows the plugin’s name and the person it acted for. The delegating person must still be able to read the item.

  • Caller: the plugin, content:propose
  • Requires: content:propose
  • Idempotency-Key (required) — as for transitions.
  • Body — $defs/proposal_command. Closed.
  • Answers: { "outcome": {...}, "data": <proposal> } — $defs/proposal.

Outcomes:

  • proposal_created / idempotent_replay
  • invalid_request
  • capability_refused
  • requester_unauthorized
  • content_not_found
  • version_conflict — base_version_id is no longer the current version
  • validation_failed
  • limit_exceeded
  • idempotency_key_reused
  • rate_limited
  • temporary_failure

Propose a value for an editor to review

Terminal window
curl -X POST "https://cms.example/api/plugin/v1/content/cdocsinstance00000000000001/proposals" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: propose:plg_0000000000000000000000000000d1ee:0001" \
--data '{
"delegation_id": "pld_0000000000000000000000000000d1ee",
"base_version_id": "cdocsversion00000000000003",
"fields": {
"title": "A better title"
},
"note": "Shorter and clearer."
}'
{
"outcome": {
"code": "proposal_created",
"message": "The proposal was created. Nothing changes until a person applies it.",
"retry": false
},
"data": {
"id": "prop_00000000000000000000000000000d1e",
"revision": 0,
"instance_id": "cdocsinstance00000000000001",
"lang": "en",
"base_version_id": "cdocsversion00000000000003",
"fields": {
"title": "A better title"
},
"note": "Shorter and clearer.",
"source": {
"kind": "plugin",
"id": "plg_0000000000000000000000000000d1ee",
"label": "My Plugin",
"requested_by": "[email protected]"
},
"state": "open",
"applied_version_id": null,
"created_at": "2026-01-01T00:00:00.000Z",
"resolved_at": null
}
}

Nothing changed. The entry editor shows it as from My Plugin, requested by the person.

One proposal this installation made. Read one proposal to learn whether a person applied or dismissed it, and which version was created when it was applied. Another installation’s proposal, or one for an item outside the grant, is content_not_found.

  • Caller: the plugin, content:propose
  • Requires: content:propose
  • No query, no body.
  • Answers: { "data": <proposal> } — $defs/proposal.

Outcomes:

  • capability_refused
  • content_not_found
  • invalid_request
  • plugin_unauthorized
  • rate_limited
  • temporary_failure

Read the person’s working draft. The delegating person’s own unsaved editor draft of one item and language — the work the entry editor restores. A draft is never a version or an audit record. It belongs to the person: a plugin reads only the draft of the person who launched it. target is an item id, or new:<model id> for an item not created yet, which needs a manage delegation. The person must still be able to change the item (or create one of that model). Drafts expire after 30 days.

  • Caller: the plugin, draft:write
  • Requires: draft:write
  • delegation_id, target, locale — all required, nothing else. $defs/draft_query.
  • Answers: { "data": <draft|null>, "expired": <boolean> } — $defs/draft_read.

Outcomes:

  • capability_refused
  • content_not_found
  • requester_unauthorized
  • invalid_request
  • plugin_unauthorized
  • rate_limited
  • temporary_failure

Write the person’s working draft. Replaces that draft only when its revision matches expected_revision (0 when there is none yet); another device or tab writing first is draft_conflict, with the current draft in data.current. A draft holds at most 1900000 bytes, and a person keeps at most 200 on a site; beyond either is limit_exceeded. data is the editor’s working state; Dee Wan validates it only when a person saves.

  • Caller: the plugin, draft:write
  • Requires: draft:write
  • Idempotency-Key (required).
  • Body — $defs/draft_write_command. Closed.
  • Answers: { "outcome": {...}, "data": <draft> } — $defs/working_draft.

Outcomes:

  • draft_saved / idempotent_replay
  • draft_conflict
  • limit_exceeded
  • invalid_request
  • capability_refused
  • requester_unauthorized
  • content_not_found
  • idempotency_key_reused
  • rate_limited
  • temporary_failure

Discard the person’s working draft. Removes exactly revision of that draft. A newer revision is kept and answered draft_conflict. A draft that is already gone is draft_discarded.

  • Caller: the plugin, draft:write
  • Requires: draft:write
  • Idempotency-Key (required).
  • Body — $defs/draft_discard_command. Closed.
  • Answers: { "outcome": {...} }.

Outcomes:

  • draft_discarded / idempotent_replay
  • draft_conflict
  • invalid_request
  • capability_refused
  • requester_unauthorized
  • content_not_found
  • idempotency_key_reused
  • rate_limited
  • temporary_failure

Upload media, as the person. The ordinary media upload, through the same verification and storage as the media library: the bytes must be one of media_types of a kind the site accepts (images and documents by default; audio and video when the site turns them on), of at most 33554432 bytes. The delegating person must hold media:upload. Any delegation of this installation may upload. Send multipart/form-data with exactly these parts: file, delegation_id, and optionally alt_texts (a JSON object of site language code to text), width, height, on_duplicate. Any other part is invalid_request. A refused file is validation_failed with the media rule’s reason. When the site already holds the same bytes, on_duplicate decides: ask (default) answers media_duplicate with the existing file and uploads nothing, use_existing answers media_reused with the existing file, copy uploads a separate copy.

  • Caller: the plugin, media:write
  • Requires: media:write
  • Idempotency-Key (required).
  • Body — multipart, $defs/media_upload_form.
  • Answers: { "outcome": {...}, "data": <media> } — $defs/media; media_duplicate carries $defs/media_duplicate.

Outcomes:

  • media_uploaded / media_reused / idempotent_replay
  • media_duplicate — the site already holds these bytes and on_duplicate is ask
  • invalid_request
  • capability_refused
  • requester_unauthorized
  • validation_failed
  • limit_exceeded
  • idempotency_key_reused
  • content_not_found — a replay whose media was since deleted
  • rate_limited
  • temporary_failure

Exchange a launch code for a delegation. The browser arrives at management_url carrying dee_wan_launch. The plugin BACKEND sends that code here, with its own bearer token, within 60 seconds. The code is consumed once. What comes back is the binding — installation, site, action, optional content instance, and model_ids: the exact granted models the person could read at launch, every entry or only their own (for a content action, the item’s model) — and a display-safe identity for the plugin’s own session. Show and act only inside model_ids; /delegations/:delegationId says what still applies now. It grants no Dee Wan capability the installation token does not already have, and a code minted for another installation is refused. No Idempotency-Key is needed: the code itself is single use, so a retry after a lost response answers invalid_request and the person has to launch again.

  • Caller: the plugin, with its installation token
  • Requires: none
  • Body — $defs/launch_exchange_request. No Idempotency-Key.
  • Answers: { "data": <delegation> } — $defs/launch_delegation.

Outcomes:

  • invalid_request — unknown, already used, expired, or minted for another installation
  • plugin_unauthorized
  • rate_limited
  • temporary_failure

Turn a launch code into a delegation

Terminal window
curl -X POST "https://cms.example/api/plugin/v1/launch/exchange" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp" \
-H "Content-Type: application/json" \
--data '{
"code": "dwl_ExampleLaunchCodeExampleLaunchCodeExampleLa"
}'
{
"data": {
"delegation_id": "pld_0000000000000000000000000000d1ee",
"installation_id": "plg_0000000000000000000000000000d1ee",
"site_id": "cdocssite00000000000000001",
"action": "schedule",
"instance_id": "cdocsinstance00000000000001",
"model_ids": [
"cdocsmodel0000000000000001"
],
"user": {
"id": "cdocsuser00000000000000001",
"email": "[email protected]"
}
}
}

Keep delegation_id. Every later command carries it, and core re-reads that person’s authority.

A launch code is single use

Terminal window
curl -X POST "https://cms.example/api/plugin/v1/launch/exchange" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp" \
-H "Content-Type: application/json" \
--data '{
"code": "dwl_ExampleLaunchCodeExampleLaunchCodeExampleLa"
}'
{
"outcome": {
"code": "invalid_request",
"message": "The launch code is unknown, used or expired.",
"retry": false
}
}

Exchange an invocation code for the bound input. Core POSTs $defs/invocation_envelope to a declared operation or policy endpoint. Before answering that POST the plugin sends the envelope’s code here with its own bearer token. The code is consumed once. What comes back is the binding core recorded: the operation or policy id, the optional model and entry, a display identity of who asked, and the bound input — operation values, or for a policy $defs/policy_facts. An entry operation’s binding also carries delegation_id: a durable delegation bound to the caller, site, installation, model and entry. Keep it like a launch delegation for later reads and transition commands on that entry; core re-reads its person’s authority on every use. A site operation’s binding carries a delegation like a manage launch: no entry, and the granted models the caller reads. A policy carries null. Core accepts the answer to its POST only from the installation that exchanged this invocation, and only while that installation still covers it. Everything happens within 10 seconds.

  • Caller: the plugin, with its installation token, while it answers an invocation
  • Requires: none
  • Body — $defs/invocation_exchange_request. No Idempotency-Key.
  • Answers: { "data": <binding> } — $defs/invocation_binding.

Outcomes:

  • invalid_request — unknown, already used, expired, or minted for another installation
  • capability_refused — the installation was narrowed, or its manifest changed, after core asked
  • plugin_unauthorized
  • rate_limited
  • temporary_failure

An invocation code works once, for the installation it was minted for

Terminal window
curl -X POST "https://cms.example/api/plugin/v1/invocations/exchange" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp" \
-H "Content-Type: application/json" \
--data '{
"code": "dwi_ExampleInvocationCodeExampleInvocationCodeE"
}'
{
"outcome": {
"code": "invalid_request",
"message": "The invocation code is unknown, used or expired.",
"retry": false
}
}

GET /api/plugin/v1/delegations/:delegationId

Section titled “GET /api/plugin/v1/delegations/:delegationId”

Read what a delegation may reach now. The delegation’s model_ids, narrowed to the live model grant and to what its person may do NOW, re-read on every request. A manage delegation lists each model its person still reads: every entry, or only their own under read:own. A content-action delegation lists its item’s model while its person may still read that item. kinds names the transition kinds the person may take on that model’s items: the installation holds workflow:publish / workflow:unpublish, the person may update the items, and the model’s workflow has an edge of that kind whose permission the person holds. entries says whether that holds for every item. all: the person reads every item of the model and kinds applies to each. checked: the person reads or updates only their own items there, so kinds is the most they may take on an item they may update; ask /delegations/:delegationId/entries which items to show and what each allows. Use it to show only what the person may see, and to offer moving or cancelling a planned transition only when its kind is listed. A delegation a management credential opened lists only kinds inside its capability ceiling. Commands are still checked on their own.

  • Caller: the plugin, with its installation token
  • Requires: none
  • No query parameter and no body.
  • Answers: { "data": { "delegation_id", "models": [{ "id", "slug", "kinds", "entries" }] } } — $defs/delegation_models.

Outcomes:

  • requester_unauthorized — the delegation is unknown, belongs to another installation, or its person is deactivated or no longer a member of the site
  • invalid_request
  • plugin_unauthorized
  • rate_limited
  • temporary_failure

What a delegation may reach now

Terminal window
curl "https://cms.example/api/plugin/v1/delegations/pld_0000000000000000000000000000d1ee" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp"
{
"data": {
"delegation_id": "pld_0000000000000000000000000000d1ee",
"models": [
{
"id": "cdocsmodel0000000000000001",
"slug": "article",
"kinds": [
"publish",
"unpublish"
],
"entries": "all"
}
]
}
}

Ask again before each page and action. A person who lost a model or a permission loses it here too.

GET /api/plugin/v1/delegations/:delegationId/entries

Section titled “GET /api/plugin/v1/delegations/:delegationId/entries”

Read which entries a delegation may reach now. For models /delegations/:delegationId marks entries: "checked". Name up to 100 entry ids; the answer lists only those the delegation’s person may read NOW, each with the transition kinds they may take on it: the model’s kinds when they may update that entry, otherwise none. An entry of another site, in the trash, of a model outside the delegation’s model_ids or the live grant, outside a content-action delegation’s translation group, or that the person may not read, is left out without a reason. Ownership and permissions are re-read on every request, so ask again before each page and action. Keep the answer private to that person: never show an entry it leaves out.

  • Caller: the plugin, with its installation token
  • Requires: none
  • instance_id — 1 to 100 times, each an entry id. Repeats count once. No other query parameter and no body.
  • Answers: { "data": { "delegation_id", "entries": [{ "instance_id", "model_id", "kinds" }] } } — $defs/delegation_entries.

Outcomes:

  • invalid_request — no instance_id, more than 100, a malformed one, or another parameter
  • requester_unauthorized — the delegation is unknown, belongs to another installation, or its person is deactivated or no longer a member of the site
  • plugin_unauthorized
  • rate_limited
  • temporary_failure

Which of these entries a delegation may reach now

Terminal window
curl "https://cms.example/api/plugin/v1/delegations/pld_0000000000000000000000000000d1ee/entries?instance_id=cdocsinstance00000000000001&instance_id=cotherinstance0000000000001" \
-H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp"
{
"data": {
"delegation_id": "pld_0000000000000000000000000000d1ee",
"entries": [
{
"instance_id": "cdocsinstance00000000000001",
"model_id": "cdocsmodel0000000000000001",
"kinds": [
"publish",
"unpublish"
]
}
]
}
}

The other site’s entry is left out, with no reason given. Show only the entries listed.

Create a saved entry. Create through ordinary lifecycle validation. A manage delegation must include the model.

  • Caller: plugin for a delegated person
  • Requires: content:create and current create authority
  • Idempotency-Key required.
  • Body: $defs/content_create_command.
  • Answers: content_saved with $defs/content_save_result.

Outcomes:

  • content_saved / idempotent_replay
  • capability_refused / requester_unauthorized / content_not_found / validation_failed / idempotency_key_reused

Move an entry to Trash. Moves the whole entry to Trash through the ordinary lifecycle. Published entries must be unpublished first. The current version is guarded atomically.

  • Caller: plugin for a delegated person
  • Requires: content:delete and current delete authority
  • Idempotency-Key required.
  • Body: $defs/content_delete_command.
  • Answers: content_deleted with the ordinary trash result.

Outcomes:

  • content_deleted / idempotent_replay
  • capability_refused / requester_unauthorized / content_not_found / version_conflict / validation_failed / idempotency_key_reused

GET /api/plugin/v1/content/:instanceId/versions

Section titled “GET /api/plugin/v1/content/:instanceId/versions”

List retained versions. Lists retained version ids with bounded pagination. Primary-entry ownership is checked.

  • Caller: plugin for a delegated person
  • Requires: content:read and current history read authority
  • Query: delegation_id required; limit, cursor optional.
  • Answers: $defs/content_versions.

Outcomes:

  • 200
  • capability_refused / requester_unauthorized / content_not_found / invalid_request

GET /api/plugin/v1/content/:instanceId/versions/:versionId

Section titled “GET /api/plugin/v1/content/:instanceId/versions/:versionId”

Read a retained version. Read one retained canonical snapshot, projected to field/locale grants and ownership.

  • Caller: plugin for a delegated person
  • Requires: content:read and current history read authority
  • Query: delegation_id required.
  • Answers: {data: <content_version>}; $defs/content_version.

Outcomes:

  • 200
  • capability_refused / requester_unauthorized / content_not_found / invalid_request

Read existing media metadata. Read site-owned media through the shared media resource service.

  • Caller: plugin for a delegated person
  • Requires: media:read and current media-library read authority
  • Query: delegation_id required.
  • Answers: {data: <media>} with presentation metadata.

Outcomes:

  • 200
  • capability_refused / requester_unauthorized / content_not_found / invalid_request

Read existing media bytes. Uses the shared provider reader for site-owned public and private bytes. External references have no local bytes. The response is private and is not cached.

  • Caller: plugin for a delegated person
  • Requires: media:read and current media-library read authority
  • Query: delegation_id required.
  • Answers: Binary file with safe content type/disposition.

Outcomes:

  • 200
  • capability_refused / requester_unauthorized / content_not_found / limit_exceeded / temporary_failure

Edit media metadata. Uses shared media-metadata validation and an atomic, idempotent write.

  • Caller: plugin for a delegated person
  • Requires: media:update and current media:update authority
  • Idempotency-Key required.
  • Body: $defs/media_update_command.
  • Answers: media_updated, {id}.

Outcomes:

  • media_updated / idempotent_replay
  • capability_refused / requester_unauthorized / content_not_found / validation_failed / idempotency_key_reused

POST /api/plugin/v1/content/:instanceId/futures

Section titled “POST /api/plugin/v1/content/:instanceId/futures”

Prepare an independent future version. Creates an immutable saved branch from the entry’s current ordinary draft, bound to this installation and release. Repeating the same installation, release and primary entry returns its existing branch. The ordinary draft and EAV values do not change. The person must still be able to read and update the entry; optional locale checks that language. A new name is trimmed and limited to 120 characters. The branch is edited through editor_url in Dee Wan, with its own revision and version CAS. It can be frozen only by an authorized publication command.

  • Caller: plugin for a delegated person
  • Requires: workflow:release and current delegated editorial authority
  • Body — $defs/future_create_command. Closed. Required: delegation_id, release_id, name; optional locale. No Idempotency-Key.
  • Answers: 201 { "data": <future with editor_url> } — $defs/future_created, including the existing branch on a repeat.

Outcomes:

  • invalid_request
  • capability_refused / requester_unauthorized / content_not_found
  • validation_failed / version_conflict / transition_refused / temporary_failure

GET /api/plugin/v1/content/:instanceId/futures/:futureId

Section titled “GET /api/plugin/v1/content/:instanceId/futures/:futureId”

Read this installation’s future version. Reads a branch only when its site, primary entry and installation match. The response includes its state, revision and current_version_id, plus a currently authorized content summary whose current_version_id names this branch. It does not return unprojected field values. The workflow summary reflects the current authority and future-publication transitions, including an authorized future-only republish. For publication, pair future_draft_id with expected_future_revision and pin expected_current_version_id to this branch’s saved version. Core rechecks required fields, review tasks, workflow, references and media before freezing and publishing; ordinary drafts remain unchanged. A retry after an uncertain result must keep the original command, version and key.

  • Caller: plugin for a delegated person
  • Requires: workflow:release and current delegated read authority
  • Query: required delegation_id — $defs/future_read_query.
  • Answers: 200 { "data": { "future": <future>, "summary": <content_summary> } } — $defs/future_read.

Outcomes:

  • invalid_request
  • capability_refused / requester_unauthorized / content_not_found

Apply a coordinated release. Plans the final state of up to 25 commands in one site. Permissions, model reach, workflow, version, locale and dependencies are checked. All publication data, workflow events and replay records commit in one guarded tenant batch, or none do. Distinct locales of a group are supported; overlapping coverage is refused. Delivery and automatic redirects follow the commit.

  • Caller: plugin for a delegated person
  • Requires: workflow:release
  • Idempotency-Key required. Body: $defs/release_command.
  • Answers: release_applied or idempotent_replay, with $defs/release_result.

Outcomes:

  • operation_applied or the documented save/release outcome
  • capability_refused, requester_unauthorized, validation_failed, version_conflict, limit_exceeded, temporary_failure

Read visitor comments. Uses the existing moderation read and current model/entry visibility.

  • Caller: plugin for a delegated person
  • Requires: comments:read
  • Query: delegation_id, plus existing management comment-list filters.
  • Answers: A bounded comment list, without private email addresses.

Outcomes:

  • operation_applied or the documented save/release outcome
  • capability_refused, requester_unauthorized, validation_failed, version_conflict, limit_exceeded, temporary_failure

Moderate visitor comments. Uses the ordinary moderation service. Consent is not fabricated; stale states are refused per comment.

  • Caller: plugin for a delegated person
  • Requires: comments:moderate
  • Idempotency-Key required. Body: delegation_id, to, comments: [{id, expected}].
  • Answers: The ordinary moderation result.

Outcomes:

  • operation_applied or the documented save/release outcome
  • capability_refused, requester_unauthorized, validation_failed, version_conflict, limit_exceeded, temporary_failure

Read redirects. Requires current site:settings authority.

  • Caller: plugin for a delegated person
  • Requires: redirects:read
  • Query: delegation_id.
  • Answers: Accepted revision and redirects only.

Outcomes:

  • operation_applied or the documented save/release outcome
  • capability_refused, requester_unauthorized, validation_failed, version_conflict, limit_exceeded, temporary_failure

Review pattern redirects. Uses the same live-address pattern review as the admin.

  • Caller: plugin for a delegated person
  • Requires: redirects:write
  • Body: delegation_id, redirects.
  • Answers: The pattern review and digest needed by the save.

Outcomes:

  • operation_applied or the documented save/release outcome
  • capability_refused, requester_unauthorized, validation_failed, version_conflict, limit_exceeded, temporary_failure

Change redirects. Only redirect changes are accepted. Existing revision, path conflict, loop and pattern-review checks apply.

  • Caller: plugin for a delegated person
  • Requires: redirects:write
  • Idempotency-Key required. Body: delegation_id, expected_revision, redirects, optional redirect_review_digest.
  • Answers: Accepted revision and redirects only.

Outcomes:

  • operation_applied or the documented save/release outcome
  • capability_refused, requester_unauthorized, validation_failed, version_conflict, limit_exceeded, temporary_failure

POST /api/plugin/v1/content/:instanceId/restore

Section titled “POST /api/plugin/v1/content/:instanceId/restore”

Restore a Trash item. Restores as a draft through the existing lifecycle, address, field/language and ownership checks.

  • Caller: plugin for a delegated person
  • Requires: content:restore
  • Idempotency-Key required. Body: delegation_id.
  • Answers: The ordinary Trash restore result.

Outcomes:

  • operation_applied or the documented save/release outcome
  • capability_refused, requester_unauthorized, validation_failed, version_conflict, limit_exceeded, temporary_failure

Delete unused media. Requires current media:delete authority. Current content, retained history and concurrent references keep media from being deleted. Storage cleanup is separate.

  • Caller: plugin for a delegated person
  • Requires: media:delete
  • Idempotency-Key required. Body: delegation_id.
  • Answers: The ordinary media deletion result and cleanup state.

Outcomes:

  • operation_applied or the documented save/release outcome
  • capability_refused, requester_unauthorized, validation_failed, version_conflict, limit_exceeded, temporary_failure

Send an approved email template. Requires current site:plugins authority and an administrator-approved template. Fixed recipients use a manage delegation. Contextual templates require instance_id inside the installation and delegation model/item grants and the actor’s current read access; a manage or matching content-action delegation may be used. The approved recipient_source chooses entry_author (the account that created the selected CMS entry, content_instance.created_by_id) or entry_assignee (its current workflow assignee, content_workflow.assigned_to_id). Entry author is not a public Blog author/profile relation or the last editor. The account must be active, have current site access and currently read the entry; an active superadministrator may qualify without an explicit site membership. No eligible account gives validation_failed. The plugin cannot supply an email address, user id, recipient policy, subject, message body or provider credential. Responses never expose the recipient address. The ordinary 16384-byte request-body limit applies, with at most 16 declared substitutions of 1000 characters each. Expanded subjects must fit 200 characters without line breaks and expanded text must fit 12000 characters; invalid expansion is refused before storing a replay record. Each accepted command snapshots its messages and recipient account. Same-key retries keep that snapshot even if the author, assignment or template changes; they recheck the actor’s current entry access and the saved recipient’s active account, site/entry access and unchanged email. A changed entry or substitutions with the same key is refused. Provider retries retain the same message identity; an unconfirmed provider result remains retryable.

  • Caller: plugin for a delegated person
  • Requires: mail:send
  • Idempotency-Key required. Body: $defs/mail_command.
  • Answers: operation_applied or idempotent_replay, {sent:true}. This confirms provider acceptance, not inbox arrival.

Outcomes:

  • operation_applied or the documented save/release outcome
  • capability_refused, requester_unauthorized, validation_failed, version_conflict, limit_exceeded, temporary_failure

POST /api/plugin/v1/content/:instanceId/renderer-metadata

Section titled “POST /api/plugin/v1/content/:instanceId/renderer-metadata”

Save renderer metadata. Writes only this installation’s declared contributions for one locale as an ordinary new saved version. Publication is separate. Core canonical SEO, verification and language links take precedence; no script or runtime CMS call is introduced.

  • Caller: plugin for a delegated person
  • Requires: renderer:metadata
  • Idempotency-Key required. Body: $defs/renderer_metadata_command.
  • Answers: The ordinary content_saved result.

Outcomes:

  • operation_applied or the documented save/release outcome
  • capability_refused, requester_unauthorized, validation_failed, version_conflict, limit_exceeded, temporary_failure

Commands and every refusal answer an outcome envelope — a machine code and a human message. Successful reads, /launch/exchange and /invocations/exchange do not: they answer { "data": … } (and "next" on a list page) with no outcome. Three refusals carry data too: validation_failed names the rule ($defs/validation_detail), draft_conflict carries the current draft as data.current, and media_duplicate carries the existing file and its copy count ($defs/media_duplicate).

{
"outcome": {
"code": "transition_applied",
"message": "…",
"retry": false
},
"data": {}
}

Branch on a validated code, HTTP status and retry tuple, not on the human message. The message must be a string, but its text is not stable. A retry flag alone is not authority.

HTTP Code Retry Meaning
200 release_applied no The release was applied atomically.
200 operation_applied no The operation was applied.
200 transition_applied no The transition was applied.
200 idempotent_replay no This command was already applied. The first result is returned.
200 content_deleted no The entry was moved to Trash.
200 media_updated no The media metadata was updated.
200 content_saved no The field values were saved as a new version.
200 draft_saved no The working draft was saved.
200 draft_discarded no The working draft was discarded.
200 media_uploaded no The media was uploaded.
200 media_reused no The site already holds this file. The existing media is returned and nothing was uploaded.
200 proposal_created no The proposal was created. Nothing changes until a person applies it.
400 invalid_request no The request is not valid for protocol v1.
401 plugin_unauthorized no The installation token is missing, unknown, disabled or revoked.
403 capability_refused no This installation was not granted that capability or model.
403 requester_unauthorized no The person who requested this no longer holds the authority it needs.
404 content_not_found no No such content in this installation’s scope.
409 scheduled_target_stale no The pinned version is no longer the one this transition would move.
409 workflow_conflict no The content moved through its workflow after this was requested.
409 dependent_content no Other content depends on this item. A person must review the unpublish in Dee Wan.
409 idempotency_key_reused no This idempotency key was already used for a different command.
409 version_conflict no The entry changed after the pinned version. Read it again before writing.
409 media_duplicate no The site already holds this file. Nothing was uploaded. Send on_duplicate to reuse it or upload a copy.
409 draft_conflict no The working draft changed after the pinned revision. Read it again before writing.
413 limit_exceeded no The request exceeds a size or count limit.
422 transition_unavailable no That transition is not available from the content’s current state.
422 transition_refused no The lifecycle refused this transition. Nothing was changed.
422 validation_failed no The values were refused by the content rules. Nothing was changed.
429 rate_limited yes Too many requests for this installation.
503 temporary_failure yes Dee Wan could not complete the request. Nothing is known to have changed.
  • Inspect a structured outcome before considering the HTTP fallback. Its known code, HTTP status and boolean retry must match the table, and message must be a string. Any mismatch or malformed outcome member is terminal untrusted_response, even on a retryable status.
  • A valid rate_limited (429) or temporary_failure (503) is retryable. Core emits temporary_failure only with 503. It does not emit that code with 500, 502 or 504.
  • A bare inbound 429/500/502/503/504 with no outcome member is retryable: this includes empty, non-JSON and intermediary error bodies. On every other status, a command body without a valid outcome is terminal untrusted_response. Redirects are not followed.
  • Network errors, timeouts and response-read failures are retryable no_response. They never prove that the command had no effect. An unreadable stored token is terminal token_unreadable.
  • Honour Retry-After on retryable replies: nonnegative integer seconds or a canonical HTTP date (IMF-fixdate). Past dates mean zero delay. Invalid values use bounded exponential backoff and jitter. A wait above the configured maximum, the attempt cap or the age cap ends as retry_exhausted.
  • A valid success is a 200 code: release_applied, operation_applied, transition_applied, idempotent_replay, content_deleted, media_updated, content_saved, draft_saved, draft_discarded, media_uploaded, media_reused, proposal_created. Every other valid non-retryable outcome is a terminal refusal under its own code.
  • Reuse the exact body and Idempotency-Key after a retry or recovered lease. Requests may repeat. Their effects must be at most once within the key retention. Only the current lease owner may start a command, with its request deadline before lease expiry. Do not deliberately execute one delivery concurrently. A timeout cannot prove the remote request stopped.
  • Keep the age cap well under 30 days. After retention, core forgets the key. Exhaustion sends nothing further and leaves any unacknowledged effect unknown. Check the content before creating a replacement schedule with a new key.
  • These are command retry rules. A successful read still needs its documented data shape. /launch/exchange consumes a single-use code and is not automatically replayable after a lost reply.

These browser-facing routes require a user session and the site header. Installation, grants, token rotation, removal and mail-template configuration require site:plugins. Named views use their declared current model and system permissions instead. A plugin token cannot call these routes; they are documented so plugin authors understand the host interface. Each one that mutates writes an audit entry, and refuses (503 audit_unavailable) rather than mutating unaudited. A refusal answers { "error": <text>, "reason": <code> }. Branch on reason, not on error. missing_permission also names the permission in required.

List available plugin workspaces. Navigation, dashboard placements and direct routes use the same live permission check. Frames stay isolated from the admin origin.

  • Caller: signed-in person
  • Requires: current declared view capabilities and readable granted models
  • Answers: Available named admin views and their reviewed frame declarations.

Outcomes:

  • 200

GET /api/plugins/views/:installationId/:viewId

Section titled “GET /api/plugins/views/:installationId/:viewId”

Open an available plugin workspace. An unavailable, disabled, ungranted or unreadable view returns not_found.

  • Caller: signed-in person
  • Requires: same live view authority as the list
  • Answers: One reviewed view.

Outcomes:

  • 200
  • 404

Review email templates and recipients. Only declared template ids and substitutions are accepted. The reviewed configuration is stored per installation and audited.

  • Caller: site administrator
  • Requires: site:plugins
  • Map of declared template id to {recipient_source?, recipients, subject, text}: at most 8 templates. Omitted or fixed recipient_source requires 1–8 approved addresses. entry_author or entry_assignee requires no addresses (recipients: []). Subject is 1–200 characters without line breaks; text is 1–12000 characters.
  • Answers: The approved template configuration.

Outcomes:

  • 200
  • 403
  • 409
  • 422

List installations. Every non-revoked installation on the site, redacted: no token, no hash.

  • Caller: a user with site:plugins
  • Requires: site:plugins
  • No body.
  • Answers: { "data": [<installation>] } — $defs/installation.

Outcomes:

  • 200
  • 403 missing_permission

Models that can be granted. Id, name and slug of every content model on the site, for the grant screen. The GRANT stores ids, so a rename keeps it and a deleted model makes that part of it inert.

  • Caller: a user with site:plugins
  • Requires: site:plugins
  • No body.
  • Answers: { "data": [{ "id", "name", "slug" }] }

Outcomes:

  • 200
  • 403 missing_permission

Read the plugin catalogues. Fetches every catalogue URL the operator set in PLUGIN_CATALOGUE_URLS, under the same URL rules and redirect refusal as a manifest. Nothing is stored and nothing is trusted: an entry only supplies a manifest URL, and installing it is the ordinary Review and Install, which fetches and validates the manifest again. A catalogue page is { "catalogue_version": 1, "plugins": [<entry>], "next"?: "https://…" }. An entry is { "id", "name", "manifest_url", "summary"?, "categories"?, "homepage"?, "icon"? }, with no other key. id follows the manifest id rule. name is at most 64 characters, summary at most 280. categories holds at most 5 lower-case slugs. Every URL passes the plugin URL rules. next must stay on the first page’s origin. Limits per catalogue: 5 pages, 128 KiB per page, 100 entries per page and 200 in all. At most 4 catalogues are read. A catalogue that breaks a rule is omitted in its entirety and named in catalogues. An invalid entry is left out and named in problems. When two entries share an id, the first one wins: configured catalogue order, then page order, then entry order. A catalogue failure never changes installed plugins or direct installation.

  • Caller: a user with site:plugins
  • Requires: site:plugins
  • No body.
  • Answers: { "data": { "configured", "entries": [{ "id", "name", "summary", "categories", "manifest_url", "homepage", "icon", "catalogue" }], "catalogues": [{ "catalogue", "origin", "ok", "entries" | "reason" }], "problems": [{ "catalogue", "index", "plugin_id", "reason", "fields" }] } }

Outcomes:

  • 200 — also when no catalogue is configured (configured: false) and when every catalogue failed. A catalogue reason is url_invalid, url_not_https, url_credentials, url_fragment, url_private_destination, remote_redirect_refused, remote_body_too_large, remote_unreachable, catalogue_unavailable, catalogue_invalid, catalogue_next_refused, catalogue_too_many_pages, catalogue_too_many_entries or too_many_catalogues. A problem reason is entry_invalid or entry_duplicate
  • 403 missing_permission

Content actions to render. The bounded { id, label } pairs active installations declare, so the content screen can render a core-owned button. No markup, no script, no CSS, no icon URL ever comes from a plugin. With instance_id, only installations whose model grant includes that item’s model are listed, and an unknown item lists none.

  • Caller: any member of the site
  • Requires: none beyond site access
  • instance_id (optional query) — the item the buttons are for. No body.
  • Answers: { "data": [{ "installation_id", "plugin_name", "actions": [{ "id", "label" }] }] }

Outcomes:

  • 200

Sidebar cards to render. The entry_sidebar or page_sidebar cards that active installations declare in ui_extensions. Core draws ordinary cards itself; an explicitly declared frame may show an isolated embedded view. A card launches through the launch route below. A content-action card is listed only for an item whose model the grant includes. A manage-view card is listed for such an item, or for a user with site:plugins. An unknown item lists none.

  • Caller: any member of the site
  • Requires: none beyond site access
  • placement (query) — entry_sidebar or page_sidebar. instance_id (optional query) — the item on screen. No body.
  • Answers: { "data": [<plugin_card>] } — $defs/plugin_card.

Outcomes:

  • 200
  • 400 invalid_placement

Operations this person may run. The operations active installations declare that this person may run now. Without instance_id: site operations (they need site:plugins) and entry operations on models the person reads. With instance_id: that entry’s operations. An operation whose requires the grant lacks is not listed, and neither is an automation operation or a content-list column. The endpoint is never listed.

  • Caller: any member of the site
  • Requires: none beyond site access
  • instance_id (optional query) — the entry on screen. No body.
  • Answers: { "data": [{ installation_id, plugin_id, plugin_name, operation }] }.

Outcomes:

  • 200

POST /api/plugins/:id/operations/:operationId/run

Section titled “POST /api/plugins/:id/operations/:operationId/run”

Run an operation. Core validates the values against the declared input, records an invocation, POSTs the envelope to the declared endpoint and waits for the exchange and the answer. A completed answer is validated against $defs/operation_result and the declared result components, and audited by digest. An exact retry with the same Idempotency-Key and body returns the first result with replayed: true.

  • Caller: a person the operation is listed for
  • Requires: the operation’s own rule: site:plugins for a site operation, read access to the entry for an entry one, update access for a destructive one
  • Body — { "instance_id"?: string, "input"?: { … } }. instance_id is required for an entry operation and refused for a site one.
  • Idempotency-Key — required.
  • Answers: { "data": { invocation_id, installation_id, plugin_id, plugin_name, operation, instance_id, outcome: "completed", result } }.

Outcomes:

  • 400 idempotency_key_required, malformed_body, invalid_request, input_invalid (with issues), input_too_large
  • 404 operation_unavailable — unknown, an automation operation, a content-list column, or not offered to this person now
  • 409 idempotency_key_reused, invocation_in_progress, plugin_unavailable
  • 422 plugin_refused — the plugin’s own refusal: { code, message }
  • 502 plugin_unreachable, plugin_response_invalid
  • 503 audit_unavailable
  • 504 plugin_timeout

Content-list columns to draw. The page operations active installations declare for one model: scope: "page", audience: "column" and a cell kind. Core adds a column headed by the operation’s label to that model’s content list. A column is listed only while the installation is active, the model is in its grant, its requires are granted and the person reads the model. A credential’s scopes and model list narrow it like any plugin surface.

  • Caller: any member of the site who can read the model
  • Requires: none beyond reading the model
  • model_id (query) — required. No body.
  • Answers: { "data": [{ installation_id, plugin_name, operation, header, cell }] }. cell is text, badge or link.

Outcomes:

  • 200
  • 400 model_id_required

POST /api/plugins/:id/columns/:operationId/cells

Section titled “POST /api/plugins/:id/columns/:operationId/cells”

Compute one column for a page of rows. Core keeps the rows the person may read now (at most 50, in the named model, not in Trash), and runs the page operation through the one invocation transport with the input $defs/column_request: the model and those row ids, no values. The plugin answers one cells block with at most one cell for each row it was asked about. A cell for another row, a repeated row, a link without an https url, a url or tone on a kind that does not take one, or text over 80 characters makes the whole answer plugin_response_invalid. A column is a read: it binds no delegation, keeps no replayed answer and writes no audit row.

  • Caller: a person who reads the model
  • Requires: none beyond reading the model
  • Body — { "model_id": string, "instance_ids": string[1..50] }. Closed.
  • Answers: { "data": { installation_id, plugin_name, operation, header, cell, cells: [{ id, text, tone?, url? }] } }. cells is empty when the person reads none of the rows or the plugin has nothing to show.

Outcomes:

  • 400 malformed_body, invalid_request
  • 404 operation_unavailable — unknown, not a column, not offered to this person now
  • 409 plugin_unavailable
  • 422 plugin_refused
  • 502 plugin_unreachable, plugin_response_invalid
  • 504 plugin_timeout

Actions for selected rows. The interactive entry operations with list_action: true that active installations declare for one model. Core offers them on the rows a person selects in the content list. A destructive one is listed only to a person who may update in the model. The endpoint is never listed.

  • Caller: any member of the site who can read the model
  • Requires: none beyond reading the model
  • model_id (query) — required. No body.
  • Answers: { "data": [{ installation_id, plugin_id, plugin_name, operation }] }.

Outcomes:

  • 200
  • 400 model_id_required

POST /api/plugins/:id/list-actions/:operationId/run

Section titled “POST /api/plugins/:id/list-actions/:operationId/run”

Run an action on selected rows. Core runs the operation once for each named row, through the same service as Run an operation, so every row is authorized against the person’s current access, validated against the declared input, audited and answered on its own. At most 25 distinct rows, run a few at a time. One row’s refusal never stops the others. Each row’s idempotency key derives from the request’s Idempotency-Key and the row id, so retrying the whole request replays the rows that already ran and runs the rest.

  • Caller: a person the operation is listed for, row by row
  • Requires: the operation’s own rule for each row: read access to the entry, update access for a destructive one
  • Body — { "instance_ids": string[1..25], "input"?: { … } }. Closed. The same input applies to every row.
  • Idempotency-Key — required.
  • Answers: { "data": { "results": [<item>], "summary": { completed, failed } } }. An item is { instance_id, ok: true, run } where run is the operation run of Run an operation, or { instance_id, ok: false, status, reason, error, refusal? } with the reason that route would answer for that row.

Outcomes:

  • 200 — whatever each row did
  • 400 idempotency_key_required, malformed_body, invalid_request
  • 404 operation_unavailable — unknown or not a list action

Field editors to render. Every active or disabled installation, with the field_editor extensions its stored manifest declares. Core draws a plugin editor only for an active installation, and only when the field still fits it. Otherwise the field keeps its stored value and core draws its built-in editor, naming the plugin and why its editor is unavailable. A model field names an editor in ui.pluginEditor ({ "plugin": "<plugin id>", "extension": "<extension id>" }). A model save that adds or changes that reference is refused unless an active installation declares the editor and the field carries its stored type, rules and options (400 plugin_editor_not_installed, plugin_editor_not_active, plugin_editor_not_found, plugin_editor_type_mismatch, plugin_editor_rules_mismatch, plugin_editor_options_mismatch or plugin_editor_invalid). An unchanged reference is kept after its plugin is disabled or uninstalled.

  • Caller: any member of the site
  • Requires: none beyond site access
  • No body.
  • Answers: { "data": [<field_editor_source>] } — $defs/field_editor_source.

Outcomes:

  • 200

Rich blocks to offer. Every active or disabled installation whose stored manifest declares rich_blocks, with each definition namespaced as <plugin id>/<name>. The rich text editor offers an active installation’s blocks in its insert menu, draws their fields itself and previews them from the declared render tree. A stored block keeps its type, version and values when its plugin is disabled or removed; the editor shows it as kept but not registered, and renderers show nothing for it on a public page. A content save that adds or changes a plugin block is refused unless an active installation declares its type (422 rich_block_unregistered), knows its version (rich_block_version_unknown) and accepts its values after the declared migrations (rich_block_values_invalid). An unchanged block is kept whatever is registered.

  • Caller: any member of the site
  • Requires: none beyond site access
  • No body.
  • Answers: { "data": [<rich_block_source>] } — $defs/rich_block_source.

Outcomes:

  • 200

Editorial views this person can open. Each installation that is not uninstalled and whose stored manifest declares a manage view (calendar, releases), with the views it declares and its state for this person: available (active, and the person reads at least one granted model, own entries included), no_readable_models (the person reads no granted model at all), disabled, or unavailable (not yet active). An empty list means no such plugin is installed. Nothing about the grant beyond that is shown.

  • Caller: any member of the site
  • Requires: none beyond site access
  • No query parameter and no body.
  • Answers: { "data": [{ "installation_id", "plugin_name", "views", "state" }] }

Outcomes:

  • 200

Fetch and validate a manifest. Fetches the manifest URL, validates it and returns what it asks for. Nothing is stored and no installation exists afterwards. This is the review step that makes approval informed.

  • Caller: a user with site:plugins
  • Requires: site:plugins
  • Body — { "manifest_url": "https://…" }
  • Answers: { "data": { "manifest": <manifest>, "protocol_version": 1 } }

Outcomes:

  • 200
  • 400 malformed_body / a manifest reason — manifest_invalid, protocol_unsupported, manifest_origin_mismatch, manifest_unavailable, url_invalid, url_not_https, url_credentials, url_fragment, url_private_destination, remote_redirect_refused, remote_body_too_large, remote_unreachable
  • 403 missing_permission

Install. Re-fetches and re-validates the manifest. Records a pending installation with the approved capabilities and model ids. Mints a token, and hands the raw token plus the one-time activation code to the manifest’s activation_url. The installation becomes active only after a 2xx from the plugin. Core keeps the hash and never the token. A failed activation leaves nothing behind. A second install of the same plugin on the same site is a conflict rather than a second installation.

  • Caller: a user with site:plugins
  • Requires: site:plugins
  • Body — { "manifest_url", "activation_code", "capabilities": […], "model_ids": […] }. The capabilities must be a subset of the manifest request. An empty model_ids means no content access, not every model.
  • Answers: { "data": <installation> }, 201.

Outcomes:

  • 201
  • 400 malformed_body / invalid_activation_code / invalid_capabilities / invalid_model_ids / a manifest reason
  • 403 missing_permission
  • 409 installation_exists / backend_url_unset / activation_superseded
  • 502 with a reason — activation_refused (a non-2xx answer), url_invalid, url_not_https, url_credentials, url_fragment, url_private_destination, remote_redirect_refused, remote_body_too_large, remote_unreachable — the plugin refused or could not be reached. Nothing is installed
  • 503 audit_unavailable

Change the grant. Replaces capabilities and model ids. Capabilities and model ids must stay within what the stored manifest requested; a capability outside it is refused, so changing the served manifest alone cannot widen a grant. A reduction takes effect on the next plugin request.

  • Caller: a user with site:plugins
  • Requires: site:plugins
  • Body — { "capabilities": […], "model_ids": […] }
  • Answers: { "data": <installation> }

Outcomes:

  • 200
  • 400 malformed_body / invalid_capabilities / invalid_model_ids
  • 403 missing_permission
  • 404 not_found
  • 409 installation_state_changed
  • 503 audit_unavailable

Choose what a content policy outage means. Sets one declared policy to warn (the default: a timeout, an outage or a malformed answer lets the write through with a warning) or required (the same failures refuse the write). A real refuse always refuses. expected_mode is the mode the administrator saw; another change in between, or an accepted upgrade, is a conflict.

  • Caller: a user with site:plugins
  • Requires: site:plugins
  • Body — { "mode": "required", "expected_mode": "warn" }
  • Answers: { "data": <installation> }

Outcomes:

  • 200
  • 400 malformed_body
  • 403 missing_permission
  • 404 not_found / policy_not_found
  • 409 policy_mode_changed with the current mode
  • 503 audit_unavailable

Turn a pre-operation hook on or off. Sets one declared hook to on or off. A declared hook is off until an administrator turns it on. While on, an upload_metadata hook is asked before a new library file is accepted and a comment_moderation hook before a visitor comment is stored; a hook that cannot answer refuses the upload or holds the comment, and never approves. expected_mode is the state the administrator saw; another change in between, or an accepted upgrade, is a conflict.

  • Caller: a user with site:plugins
  • Requires: site:plugins
  • Body — { "mode": "on", "expected_mode": "off" }
  • Answers: { "data": <installation> }

Outcomes:

  • 200
  • 400 malformed_body
  • 403 missing_permission
  • 404 not_found / hook_not_found
  • 409 hook_mode_changed with the current mode
  • 503 audit_unavailable

Accept a new manifest version. Re-fetches the stored manifest URL and accepts the release an administrator has just reviewed. expected_version must match what is served, or the review is stale. The plugin id, the origin and the accepted protocol version must not have moved. Capabilities are INTERSECTED with the new request. An upgrade never widens a grant. A release that changes the accepted protocol version cannot be accepted: uninstall and install it again. A policy the new release no longer declares loses its mode; a new policy starts as warn.

  • Caller: a user with site:plugins
  • Requires: site:plugins
  • Body — { "expected_version": "0.2.0" }
  • Answers: { "data": <installation> }

Outcomes:

  • 200
  • 400 malformed_body / a manifest reason
  • 403 missing_permission
  • 404 not_found
  • 409 manifest_identity_changed / protocol_changed / manifest_changed_since_review / installation_state_changed
  • 503 audit_unavailable

Rotate the token. Needs a fresh one-time code from the plugin, because rotation is a hand-off and not a reset. Core mints a token, sends it to the pinned activation origin, and only then swaps the hash. If the plugin refuses, the old token keeps working. The previous hash stays valid for at most 10 minutes, and the first request carrying the NEW token ends that overlap immediately. From then on the old token answers 401 plugin_unauthorized. A plugin with several workers must switch them all together. Alternatively, a worker can re-read the stored token once when it gets 401 right after a rotation.

  • Caller: a user with site:plugins
  • Requires: site:plugins
  • Body — { "activation_code": "…" }
  • Answers: { "data": { "rotated": true } }

Outcomes:

  • 200
  • 400 invalid_activation_code (also for a body that is not a JSON object)
  • 403 missing_permission
  • 404 not_found
  • 409 installation_not_active / backend_url_unset / installation_state_changed
  • 502 with a reason — activation_refused (a non-2xx answer), url_invalid, url_not_https, url_credentials, url_fragment, url_private_destination, remote_redirect_refused, remote_body_too_large, remote_unreachable — and the OLD token keeps working
  • 503 audit_unavailable

Disable. Keeps the configuration and the audit trail and stops the plugin API dead: every request answers plugin_unauthorized before any tenant access. Its cards disappear, and fields that name its field editors keep their values and are drawn with the built-in editor.

  • Caller: a user with site:plugins
  • Requires: site:plugins
  • No body.
  • Answers: { "data": { "id", "state": "disabled" } }

Outcomes:

  • 200
  • 403 missing_permission
  • 404 not_found
  • 409 installation_state_changed
  • 503 audit_unavailable

Enable. Returns a disabled installation to active, with the same token and the same grant.

  • Caller: a user with site:plugins
  • Requires: site:plugins
  • No body.
  • Answers: { "data": { "id", "state": "active" } }

Outcomes:

  • 200
  • 403 missing_permission
  • 404 not_found
  • 409 installation_state_changed
  • 503 audit_unavailable

Uninstall. Revokes first: the state becomes revoked, both hashes are dropped, and the token can never become valid again. Audit attribution survives, because the workflow event stored a snapshot of the installation rather than a foreign key. Dee Wan does NOT claim this deleted anything the plugin holds — remote deletion is the plugin’s own action. Uninstall is allowed while model fields name its field editors: those fields keep their values and rules and are drawn with the built-in editor.

  • Caller: a user with site:plugins
  • Requires: site:plugins
  • No body.
  • Answers: { "data": { "id", "state": "revoked" } }

Outcomes:

  • 200
  • 403 missing_permission
  • 404 not_found
  • 409 installation_state_changed
  • 503 audit_unavailable

Open the plugin, delegated. Mints a one-time code, valid for 60 seconds, stored hashed, bound to installation, site, user, action and optional instance. The result is the management_url with the code on it. The response carries Referrer-Policy: no-referrer and Cache-Control: no-store. action must be manage with no instance, or a content_actions id from the stored manifest with one. For a content action the item’s model must be in the installation’s grant. A manage launch may name a view from the stored manifest’s manage_views; the URL then also carries dee_wan_view, a landing hint that grants nothing. A view launch is editorial: it needs no site:plugins, only one granted model the person reads, every entry or only their own. A bare manage launch is plugin administration and needs site:plugins. The delegation records model_ids: the granted models the person reads (for a content action, the item’s model), narrowed to a management credential’s models when one acts. A launch a management credential makes also records a capability ceiling: the installation’s capabilities that the credential’s scopes allow. Every later command through that delegation is refused capability_refused outside it. Dee Wan session cookies never leave Dee Wan.

  • Caller: a site member for a content action or a manage view, a user with site:plugins for a bare manage
  • Requires: read access to the named item, for a content action; one readable granted model, for a view
  • Body — { "action": "manage" }, { "action": "manage", "view": "calendar" } (or "releases") or { "action": "<action id>", "instance_id": "…" }
  • Answers: { "data": { "url": "https://…?dee_wan_launch=…" } } — $defs/launch.

Outcomes:

  • 200
  • 400 malformed_body / invalid_launch_action
  • 403 missing_permission (for a bare manage) / no_readable_models (a view launch, and the person reads no granted model at all) / model_denied / not_owner (the person may not read the item) / model_not_granted (the item’s model is outside the grant)
  • 404 not_found — no such installation, or no such item
  • 409 installation_not_active

A field_editor extension with control: "group" draws one JSON object field as up to 8 labelled members: text (max_length up to 500), number (min, max, integer), boolean and select (up to 12 options). Member keys are unique. The stored value stays one JSON object of at most 8192 bytes. Choosing the editor in the model builder writes its closed schema onto the field (config.schema, with validationType: "object"), and the ordinary save path validates supplied members and structure: values outside bounds or choices, unknown member keys and non-object values are refused. Draft mode may omit required members; strict saves and every publication require them. The schema stays on the field when the plugin is disabled or uninstalled, so the stored values stay valid and the built-in JSON editor draws them.

Existing summary reads stay usable. Add delegation_id to GET /content/:instanceId to receive saved: { id, version_number, data }. data holds canonical snapshot values, relations and media. Saved data is projected according to the current actor’s model, field, locale and ownership rules. Without a delegation, only the summary is returned.

GET /content/:instanceId/versions?delegation_id=…&limit=50&cursor=… lists retained versions. GET /content/:instanceId/versions/:versionId?delegation_id=… reads one projected snapshot. History checks primary-entry ownership. Pruned versions return content_not_found.

POST /content creates an ordinary saved entry. It requires the content:create capability, a manage delegation and Idempotency-Key. The required body fields are delegation_id, model_id and locales; optional fields are primary_lang, slugs, relations, locale_relations, media, seo and label. These shapes match the management entry-create contract. The lifecycle checks create, model, field and locale grants, as well as every target. It returns content_saved.

PUT /content/:instanceId continues to accept the existing fields, locale, expected_version_id and delegation_id properties. Optional relations, locale_relations and media use the management write shapes. fields may be empty when a reference write is present. The same lifecycle validation, optimistic version pin and idempotency rules apply.

DELETE /content/:instanceId moves the whole entry to Trash. The body contains delegation_id and expected_version_id. It requires content:delete, the actor’s delete grant and Idempotency-Key. Live entries must be unpublished first. It returns content_deleted; a retry returns the first result. An atomic version guard prevents concurrent loss.

GET /media/:mediaId?delegation_id=… reads metadata. /bytes reads existing provider bytes. Both require media:read and the current actor’s media-library read access. Site isolation applies to public and private files. The byte response is private, is not cached and uses a safe disposition. External references have no local bytes. PUT /media/:mediaId accepts { delegation_id, metadata } and Idempotency-Key. Metadata uses the management shape: alt_texts, default_focal, loading, fetch_priority. It requires the actor’s media:update grant. The shared media service validates framing. It returns media_updated, { id }.

Existing cards and field editors may declare frame: { url, origin, operations? }. origin must exactly match the frame URL’s origin and the plugin origin. The origin is reviewed before installation is accepted. Operations must name this manifest’s declared operations. An upgrade adds no permission grants.

The host iframe is credentialless, uses sandbox="allow-scripts" and sends no referrer. It receives no admin cookies or plugin token. On load, the host sends dee-wan:connect, protocol 1, the field value and disabled state, transferring one MessagePort. The child uses only the transferred port and sends {type:"ready"}. For a field change, it sends {type:"change",value}; the ordinary editor save validates the value. The host sends the current {type:"value",value,disabled} after updates. For a resize, the child sends {type:"resize",height}; the host bounds the height. For an operation, the child sends {type:"operation",id,operation,input}. The host checks that the action is declared and currently offered to the actor, then uses the existing operation endpoint. The host answers {type:"result",id,result}. A repeated id reuses the same result. No arbitrary fetch endpoint, installation token or launch code enters the frame.

Rich blocks add relation controls (models, optional multiple) and group controls (fields, optional repeatable, max_items). Groups support up to three nested levels and fifty items per group. They use normal typed field inputs and the relation picker. During lifecycle saves, relation values are checked against current site, model and actor read grants. The render tree uses {group,children} or {each,children} to scope child bindings. Top-level and nested media controls retain ordinary media pins.

Paragraph and heading alignment uses data-align plus the canonical text-align style. The values are left, center, right and justify. The editor, saved rich HTML and supported renderers share this contract.

Vendor snippets store a typed snippet rich block with html, css, javascript, title. Canonical HTML stores JSON in an inert figure. The admin preview and supported renderers use dedicated static frame endpoints and the same MessageChannel payload. The public parent bridge is hash-approved. The frame CSP enforces an opaque sandbox, and vendor code stays inside the frame. Both use the same document payload in credentialless frames with no same-origin permission; scripts, forms and popups run only inside the frame. The provider owns the submitted booking or form data. Never store provider credentials in snippet content.

A declared relation keeps IDs in saved data. Public rich output replaces each readable target with a bounded reader-only reference: reference: true, instance_id, current title, usable url, and properties containing only projected scalar target fields named by render-node property. { field: "target", property: "address" } reads that target field; an ordinary { field: "target" } shows its title/name. Relation anchors use the shared live Page/Blog/managed-route address or a projected public url field. Targets with no address stay text. Hidden targets and private fields are omitted. Normal save still accepts IDs only; resolved labels and URLs cannot be submitted as authority.

  • schemas/manifest-v1.schema.json — the manifest, generated from the object core validates every fetched manifest with.
  • schemas/protocol-v1.schema.json — requests, responses, the activation hand-off body, outcomes, limits and secret shapes, generated from the same definitions. Request shapes are closed. Response shapes allow additional properties, because clients must ignore response keys they do not know.

JSON Schema is not the entire semantic validator. Enable format validation: URL fields use uri, while the management URL uses uri-template. Manifest, base, activation and substituted management URLs must share one origin and pass the HTTPS/private-destination policy. Only exact development origins are exempt. DNS resolution is not checked. Capabilities are unique. Content action ids must also be unique. ui_extensions ids are unique, are not one of reserved_ids, and a card launches only a content_actions id or a manage_views entry the same manifest declares. At least one protocol version must be supported. Approved grants must stay within the manifest request and this site’s models. Transition kind determines its one required version pin. Status/code/retry agreement is checked after shape validation.


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