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 MCP bearer the content-editor agent uses to call back into the CMS.
- 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. - For WordPress, the equivalent uses HTTP basic auth (username + application password) against the WordPress REST API at
/wp/v2/posts/*.
- 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 registers an analogous but slightly larger set:
wordpress_status,wordpress_instances_list— metadata.wordpress_post_get,wordpress_posts_list,wordpress_post_get_latest,wordpress_post_status— read.wordpress_post_create_draft,wordpress_post_update,wordpress_post_update_meta,wordpress_post_delete— write.wordpress_media_upload— media library.wordpress_content_editor_run— dispatch thewordpress-content-editorWayFlow agent.
Each primitive is Zod-validated. 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.
WordPress pages vs posts: the current page contract
Section titled “WordPress pages vs posts: the current page contract”The WordPress primitives are post-shaped by default and only partially page-aware — page support is per-primitive rather than global, and which page operations an instance can perform depends on the connector version it runs:
- Read and update a page — supported.
wordpress_post_getandwordpress_post_updateaccept an optionalpostType. When a caller passespostType: "page", the primitive routes to/wp/v2/pages/{id}instead of/wp/v2/posts/{id}, so an external/api/mcpclient — or the content-editor agent — can read and edit a page, provided it already knows the page’s numeric ID. - Discover / list pages — landing in the next connector release. A dedicated
wordpress_pages_listprimitive (page-only, offset-paginated likewordpress_posts_list) has merged into the connector’smainand ships in the next connector release.wordpress_posts_listitself stays post-only, so page discovery is this separate primitive rather than apostTypeon the posts list. Until an instance runs a release that includes it, page IDs still cannot be listed through Cinatra’s primitives and must be obtained out of band (for example, from the page’s WordPress admin URL). - Page status and delete — page-aware in the next connector release.
wordpress_post_statusandwordpress_post_deletenow acceptpostType: "page"and route to/wp/v2/pages/{id}on the connector’smain, shipping in the same upcoming release; on the currently released connector both remain post-only. - Create a page — still post-only.
wordpress_post_create_draftremains post-route based and ignorespostType, so drafting a page is not yet supported through Cinatra’s primitives.
Page listing and page-aware status/delete have already landed in cinatra-ai/wordpress-mcp-connector’s main, but are not yet in a tagged connector release — so an instance on the current release still sees the older post-only behavior for those, while page create remains an open gap. The bullets above describe both the source-of-truth contract and what a released connector exposes today.
This contract is separate from the external WordPress MCP Adapter server (WordPress/mcp-adapter), which Cinatra can inject as an additional toolbox for a public WordPress site: what page tools that server exposes depends on the adapter plugin’s own behavior and version, not on the Cinatra primitives above.
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 (wordpress_post_update with status: "draft") |
| Read with edit context | Recent-content list (mcp_tools_get_recent_content) filtered by node ID — mcp_tools has no get-by-ID tool |
Direct REST lookup (/wp/v2/posts/{id}?context=edit) |
| Auth to the CMS-side endpoint | Bearer token (mcp_tools remote key) |
HTTP basic (username + application password) |
| 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) |
number (positive integer, coerced at schema level) |
| Media | Inline in the node structure | Separate wordpress_media_upload primitive |
The WordPress connector also enforces an “at least one field” refinement on wordpress_post_update 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
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.