Integrating Cinatra with a CMS
Cinatra ships two reference CMS integrations — WordPress and Drupal — that embed a Cinatra-driven AI assistant inside the CMS authoring surface. This page is the protocol-level reference for both. The user-facing companion covers the editor and admin experience.
The architecture is symmetric across the two CMSes. The walkthrough below uses Drupal where the two diverge; the WordPress equivalents are noted inline.
The two halves
Section titled “The two halves”A CMS integration has two halves:
- The CMS-side plugin/module — code installed on the CMS that injects a chat widget into editor pages and brokers the conversation back to Cinatra.
- The Cinatra-side stream route + content-editor agent — receives the widget’s chat requests, orchestrates a large language model (LLM) turn against a content-editor function tool, and produces typed field diffs the widget renders.
Auth and CORS live in the Cinatra app; the widget bundle ships inside the CMS-side plugin/module (see below). The CMS-side code is intentionally thin — it loads its local widget bundle, holds the per-instance credentials, and gets out of the way.
The CMS-side artifact
Section titled “The CMS-side artifact”For Drupal: the cinatra-ai/drupal-module repository — a PHP module installable via Composer or manual placement in the Drupal modules/custom/ tree. Its cinatra.module and supporting src/ directory:
- Register an admin settings form at
/admin/config/services/cinatra(cinatra.routing.yml). The form captures Cinatra URL, API key, and instance ID. - Implement
cinatra_page_attachments()to inject the widget bundle on node canonical view, node edit form, and the site front page — and only for authenticated Drupal users (!\Drupal::currentUser()->isAuthenticated()early-returns). - Pass the configured Cinatra URL + instance ID to the bundle via
drupalSettings.cinatra. The long-lived widget API key is not placed indrupalSettings— it stays server-side, and the bundle obtains a short-lived streaming token from the module’s same-origin token-broker route instead.
For WordPress: the cinatra-ai/wordpress-plugin repository — a standalone WordPress plugin, extracted out of the platform monorepo, whose main file cinatra.php ships alongside the vendored widget bundle under assets/. It:
- Adds a Settings → Cinatra admin page capturing Cinatra URL, API key, instance ID, and an optional webhook secret.
- Enqueues the widget bundle on WordPress admin pages, only for users with the
manage_optionscapability (administrator-level). It does not load on the public front-end and is not visible to lower-privileged editors. - Exposes the configured values to the bundle as
window.CinatraConfig— the non-secret connection settings only; the long-lived integration credential stays server-side and is never placed here (see Auth model). - Runs the same-origin token broker: a plugin REST route the browser calls to obtain a short-lived, scoped stream token. The plugin’s server-side code holds the long-lived widget API key and exchanges it — server-to-server with Cinatra’s token endpoint — for that short-lived token, so the browser receives only the short-lived token and never the raw key.
- Also registers REST endpoints under
/wp-json/cinatra/v1/*for webhook subscription management (list, create, delete, plus an HMAC-signed receive endpoint).
Both artifacts are credential carriers, local widget loaders, and same-origin token brokers: each holds the long-lived integration credential server-side and mints short-lived streaming tokens for the browser. The WordPress plugin additionally carries the webhook subscription surface — when Cinatra wants to notify the CMS of an event (e.g., a Cinatra-side LinkedIn publish completed), it posts to the WordPress REST endpoint signed with the configured webhook secret.
The widget bundle
Section titled “The widget bundle”The widget JavaScript is vendored inside the CMS-side package — it is never remote-loaded from a Cinatra instance:
- WordPress:
wordpress-plugin/assets/cinatra-widget.js, enqueued locally bycinatra.phpviaplugins_url(...). - Drupal:
drupal-module/js/cinatra-widget.js, attached as the localcinatra/bundlelibrary bycinatra.module.
The bundle is an IIFE that mounts a shadow-DOM widget on the CMS page, opens a chat panel when clicked, and posts messages to the Cinatra stream endpoint described below. Loading executable JS from a per-customer Cinatra origin into a CMS admin page is the rejected pattern that motivated this shape: the Cinatra instance is a versioned data API only, and a widget change reaches an already-installed site only via a CMS package release (plugin/module update), never a live push. The former host-served bundle routes (/api/{drupal,wordpress}/bundle.js) were dead pre-cutover artifacts and have been removed — never author widget behavior into a host route. The normative contract is docs/widget-source-of-truth.md in the platform repo.
The stream endpoint
Section titled “The stream endpoint”The widget bundle does not call Model Context Protocol (MCP) primitives directly. It calls a single stream route per CMS:
POST /api/agents/drupal-content-editor/streamPOST /api/agents/wordpress-content-editor/stream
Both are handled by src/app/api/agents/[agentSlug]/stream/route.ts, a per-slug agent stream registry. The route:
- Validates the CMS origin against the configured allowlist (
resolveWidgetStreamOriginin the genericsrc/lib/widget-stream-auth.ts— one CMS-agnostic module for every widget-stream slug). - Validates the
Authorizationbearer token (validateWidgetStreamToken). The underlying widget-auth config is global per CMS kind — onedrupal_widget_auth/wordpress_widget_authrecord per Cinatra install, not per instance — and the store itself is owned by the CMS connector: each connector registers its widget-auth store as a host capability (@cinatra-ai/host:wordpress-widget-auth/@cinatra-ai/host:drupal-widget-auth) from itsregister(ctx), and the host resolves the store lazily at call time (e.g.src/lib/widget-auth-provider.tsfor WordPress); the core ships no vendor widget-auth module. - Calls
streamfrom@cinatra-ai/llmwith:- The widget’s message history (capped at the most recent N user/assistant turns).
- A widget-chat function tool built by the connector —
createDrupalWidgetChatTool(@cinatra-ai/drupal-mcp-connector/widget-chat-tool) orcreateWordPressWidgetChatTool(@cinatra-ai/wordpress-mcp-connector/widget-chat-tool). When the LLM calls this tool, it invokes the connector’sdrupal_content_editor_run/wordpress_content_editor_runMCP primitive, which dispatches to a WayFlow (Cinatra’s OAS Flow agent runtime) content-editor agent through the host-bounddispatchContentEditordependency. - The standard skill tool surface so skills can shape the assistant’s behavior.
- Streams the LLM response back to the widget as server-sent events (SSE).
The SSE wire format is frozen — clients in the wild depend on it:
| Event | Payload | Meaning |
|---|---|---|
text |
{ content: string } |
A text chunk to append to the chat panel. |
changes |
{ fields: [{ field, before, after }], nodeId: string, postId: string } |
A typed field-level diff describing what the agent wrote. Both nodeId and postId are always present as strings, regardless of which CMS the stream serves; clients pick the one they care about. The diff describes changes that have already been applied to the CMS draft. |
error |
{ message } |
A terminal error; the chat ends. |
done |
{} or { fallback: true } |
Stream complete. The default empty payload signals normal completion; fallback: true signals the agent could not produce changes and only the chat-only text response is meaningful. |
The route’s path is allowlisted in src/lib/auth-route-guard.ts so unauthenticated browser widgets reach it instead of being redirected to /sign-in.
Auth model
Section titled “Auth model”Two credentials are involved; they must not be confused.
-
The widget API key.
- Generated server-side and stored in
connector_configkeyed bydrupal_widget_auth/wordpress_widget_auth— a store each CMS connector owns and registers as a host capability (see the stream-endpoint section above). - Copied by the admin into the CMS plugin/module settings form. It stays server-side on the CMS — the vendored widget never sees the raw key in the browser.
- Exchanged, per session, through the CMS’s same-origin token broker for a short-lived, origin/audience/scope-bound token; the stream request is Bearer-authenticated with that short-lived token, never the raw API key.
- Scope: widget chat + content-editor function tool only. Not an OAuth grant; it does not unlock the full MCP primitive catalog.
- Generated server-side and stored in
-
The per-instance CMS credential Cinatra uses to call into the site.
- For Drupal, the
drupal-content-editorWayFlow agent calls into the configured Drupal site’smcp_toolsmodule at<siteUrl>/_mcp_toolsto read fields, create draft revisions, and write updates. The credential is a separate per-instance MCP key configured on the Drupalmcp_toolsside, sent as a Bearer token. - For WordPress, the credential is the instance’s admin username + Application Password, presented as HTTP Basic auth to the site’s own MCP catalog endpoint: every content read/write through
wordpress_site_tool_call/wordpress_site_tools_list— including the content-editor agent’s edits behind them — authenticates this way, through the governed connector-instance invoker. The same Application Password also backs a small direct-REST carve-out that remains for exactly three operations — media upload, post delete, and post status — which call WordPress core REST routes (/wp/v2/media,/wp/v2/(posts|pages)/{id}) directly rather than a catalog ability.
- For Drupal, the
The two credentials live in different stores and have different rotation lifecycles. Rotating the widget API key on Cinatra does not affect the CMS-side MCP/REST credential, and vice versa.
For the wider Cinatra auth model (Better Auth (the auth server library Cinatra uses), OAuth-provider plugin, MCP JWTs), see Authentication.
The MCP primitives the connectors register
Section titled “The MCP primitives the connectors register”The connector extensions each register a small primitive set the content-editor agent (and any other Cinatra surface) can call.
@cinatra-ai/drupal-mcp-connector registers:
drupal_status— connection status for a configured instance.drupal_instances_list— every configured Drupal instance on this Cinatra deployment.drupal_node_get,drupal_node_list— read node data.drupal_node_create_draft_revision— create a new draft revision on a published node.drupal_node_update,drupal_node_publish— write the draft, then publish.drupal_content_editor_run— dispatch a high-level edit task to thedrupal-content-editorWayFlow agent.
@cinatra-ai/wordpress-mcp-connector takes a different shape. Rather than a fixed primitive per operation, it registers a generic, governed gateway onto a connected site’s own MCP catalog, plus the same kind of dispatch relay Drupal has:
wordpress_site_tool_call,wordpress_site_tools_list— list a connected site’s own MCP catalog, then call any ability it advertises by name (toolName+args), through the governed connector-instance invoker. See The WordPress catalog gateway, trusted-site mode, and the review gate below.wordpress_content_editor_run— dispatch a high-level edit task to thewordpress-content-editorWayFlow agent.
Each primitive is Zod-validated at its own input envelope. Each runs through the standard MCP authorization gate. The primitives are also reachable from the external MCP server at /api/mcp — an external client with the right credentials can drive WordPress or Drupal from outside the embedded widget.
The WordPress catalog gateway, trusted-site mode, and the review gate
Section titled “The WordPress catalog gateway, trusted-site mode, and the review gate”WordPress content operations are not exposed as fixed, named Cinatra primitives. wordpress_site_tool_call and wordpress_site_tools_list are a generic gateway onto whatever MCP catalog the connected site itself advertises — today, that means the community “Enable Abilities for MCP” plugin layered on WordPress’s Abilities API (WordPress/mcp-adapter plus WordPress/abilities-api). Call wordpress_site_tools_list first to see the exact ability ids, schemas, and policy status a given site exposes, then call one by name with wordpress_site_tool_call — for example ewpa/get-post, ewpa/get-posts, ewpa/create-post, ewpa/update-post, ewpa/update-post-meta. Both primitives route through the governed connector-instance invoker (per-instance authorization, per-instance tool policy, ability classification, a destructive-confirmation hold on the chat/session surface, execution, and audit — all resolved host-side), so which abilities a caller can actually reach depends on the connected site’s own catalog and that instance’s tool policy, not on a Cinatra-maintained operation list. There is no dedicated Cinatra primitive for pages versus posts, media upload, or any other single operation; if the site’s catalog advertises an ability for it (for example ewpa/get-page, for reading a page by ID), it is reachable the same way as any other ability — and if the catalog doesn’t advertise one, Cinatra has nothing to fall back to.
Trusted-site mode is a separate, opt-in path that only applies to workspace chat. Enabled per instance from the WordPress connector’s settings page, it lets a connected site’s read-only catalog tools be injected directly into the model provider’s own toolbox instead of being mediated call-by-call through the governed invoker. It only ever injects a host-verified, non-empty read-tool allowlist, requires a current consent acknowledgement, and is re-evaluated on every assembly — agent runs, the public widget, and every other surface never get an injected toolbox. Writes are never injected this way; they always go through wordpress_site_tool_call. On the community plugin versions Cinatra has verified so far, the verified read-tool set is empty, so trusted-site mode ships built but currently injects nothing, even for a fully opted-in site.
The review-before-publish gate still applies on the generic path. Calling ewpa/update-post through wordpress_site_tool_call runs the same review-before-publish check Cinatra’s content-review system applies to any externally-published artifact: the proposed title/content/excerpt/status change is diffed against the live post, and if a tracked field actually changed, the write is held for a human to approve before it reaches WordPress — never applied silently. It also refuses a call with no editable field, and refuses any argument outside that reviewed set. Other write abilities the site’s catalog may expose are not currently covered by this gate.
What WordPress and Drupal don’t share
Section titled “What WordPress and Drupal don’t share”Even with symmetric integrations the underlying CMSes diverge in places the agent and the connector need to handle explicitly.
| Concern | Drupal | WordPress |
|---|---|---|
| Draft-before-edit | True draft revision (drupal_node_create_draft_revision) |
Demote-then-edit pattern (ewpa/update-post with status: "draft", called via wordpress_site_tool_call) |
| Read with edit context | Recent-content list (mcp_tools_get_recent_content) filtered by node ID — mcp_tools has no get-by-ID tool |
ewpa/get-post ability via wordpress_site_tool_call, through the governed connector-instance invoker — no direct REST call |
| Auth to the CMS-side endpoint | Bearer token (mcp_tools remote key) |
HTTP basic (username + application password) against the site’s own MCP catalog endpoint |
| ID type | string at the schema level; handlers parse it to a positive integer and send nid as a string (works around a strtolower() type quirk in mcp_tools) |
wordpress_site_tool_call’s own args are forwarded to the target ability as provided; the review-gated ewpa/update-post path additionally validates post_id as a positive integer before forwarding |
| Media | Inline in the node structure | No model-visible primitive — callers reach media only if the site’s own catalog advertises an upload-capable ability; Cinatra’s internal pipelines use the direct-REST media-upload carve-out (see Auth model) |
The review-gated ewpa/update-post path also refuses a call with no editable field (title/content/excerpt/status), to prevent silent no-ops. See wordpress-mcp-connector/AGENTS.md for the connector-package-internal conventions.
Adding a third CMS
Section titled “Adding a third CMS”The integration shape is replicable. To integrate Cinatra with another CMS (e.g., Strapi, Sanity, Contentful, Ghost):
- Write a connector extension at
extensions/cinatra-ai/<cms>-connector/(kind-at-end naming; declarecinatra.kind: "connector"inpackage.jsonso theConnectorExtensionTypeHandlerrecognises it — seereferences/platform/extensions.md§ Connector extension) that registers the CRUD primitives the CMS supports (<cms>_status,<cms>_post_get, etc.) plus a<cms>_content_editor_runprimitive that dispatches a WayFlow agent. If the connector needs host-internal@/lib/*modules (database, mcp-pagination, etc.), do not import@/lib/*directly — the host publishes those services at boot (src/lib/register-host-connector-services.ts) and the connector’s ownregister(ctx)server entry pulls what it needs viactx.capabilities.resolveProviders(<id>)(dependency injection keeps the package host-agnostic; adding a connector requires no edit to the host-side publication file). - Build the content-editor agent as its own extension repo (e.g.
github.com/cinatra-ai/<cms>-content-editor-agent), materialized underextensions/cinatra-ai/<cms>-content-editor-agent/— a WayFlow flow that reads the current document, produces a diff, and writes it back through the CMS’s primitives. - Author the widget-chat function tool as a
widget-chat-toolsubpath export of the connector package (the existing two are@cinatra-ai/drupal-mcp-connector/widget-chat-tooland@cinatra-ai/wordpress-mcp-connector/widget-chat-tool) so/api/agents/[agentSlug]/streamcan call it. - Add a new entry to the per-slug agent stream registry — no new route file is needed; the catch-all already routes by
agentSlug. - Implement the CMS-side artifact — a plugin, module, or app installable on the target CMS that loads the widget bundle and holds the credentials.
- Add admin pages at
/configuration/connectors/<cms>-widgetand/configuration/assistants/<cms>-widgetto manage the widget credentials and the assistant configuration.
The two existing CMS connector extensions (drupal-mcp-connector, wordpress-mcp-connector) are the canonical reference for the shape. Read drupal-mcp-connector/AGENTS.md first — its conventions are documented for exactly this case.
Source-of-truth files
Section titled “Source-of-truth files”When you need to verify a specific claim on this page:
- Drupal module:
cinatra-ai/drupal-module - WordPress plugin:
cinatra-ai/wordpress-plugin— the extracted plugin repository (main filecinatra.php, the same-origin token broker, and the vendored widget bundle) - Drupal widget bundle (vendored):
drupal-module/js/cinatra-widget.js - WordPress widget bundle (vendored):
wordpress-plugin/assets/cinatra-widget.js(incinatra-ai/wordpress-plugin) - Widget source-of-truth contract:
docs/widget-source-of-truth.md(platform repo) - Stream route:
src/app/api/agents/[agentSlug]/stream/route.ts - Widget stream auth (generic):
src/lib/widget-stream-auth.ts - Connector-owned widget-auth store resolution:
src/lib/widget-auth-provider.ts(WordPress) and each connector’s register entry (register.tsin the connector repo) - Drupal connector:
drupal-mcp-connector/src/ - WordPress connector:
wordpress-mcp-connector/src/ - Drupal widget-chat tool:
drupal-mcp-connector/src/widget-chat-tool.ts - WordPress widget-chat tool:
wordpress-mcp-connector/src/widget-chat-tool.ts - WordPress catalog gateway internals (page/post ability behavior, troubleshooting):
wordpress-mcp-connector/docs/external-mcp-adapter-pages.md
Where to go next
Section titled “Where to go next”- The user-facing companion: Cinatra in your CMS in the User Guide
- The MCP primitive contract every CMS connector registers: Primitives
- The streaming wire format the widget rides: Open standards in Cinatra
- The shared authorization model: Security
CMS restore via the remote-effect state machine
Section titled “CMS restore via the remote-effect state machine”CMS edits do NOT participate in local DB atomicity — Cinatra cannot
roll back a WordPress publish or a Drupal node update by aborting a
Postgres transaction. The data-safety substrate handles this via
an explicit pending → succeeded | failed state machine that lives in
the remote_effect_attempts table, keyed to the canonical
object_change_event.id. The append-only history surface stays
append-only; the mutable state lives on the separate table.
Connector contract for CMS restores:
import { runCmsRestore } from "@/lib/object-history";
await runCmsRestore({ changeEventId: event.id, // points at the local history event connectorName: "wordpress", targetKind: "wordpress-post", targetId: String(remoteRevisionRef.remoteId), intendedState: { title, content, status }, idempotencyKey: `restore_${changeEventId}_wp`, orgId, callable: async ({ intendedState }) => { // 1. POST the captured snapshot to the CMS REST API. // 2. Read back to verify the remote reflects the intended state. // (DSUV-CMS-03 — read-back-verify before mark succeeded.) // 3. Return the new remote revision id + the read-back payload. return { remoteRevisionRef: { revisionId }, readBack }; },});Every CMS restore implementation MUST:
- Be idempotent — re-executing with the same
idempotencyKeyyields the same remote state. - Read back and verify the post-write remote state before recording
succeeded. - Fail loudly (throw) when the remote rejects or read-back diverges; the
state machine records
failedwith the error message.
See Data safety: undo and versioning §10 for the full state-machine contract and §9 for how the WordPress freshness adapter feeds eligibility decisions back into the restore engine.
Docs content licensed under CC-BY-4.0; embedded code snippets under Apache-2.0.