ADR-0019: Elementor cockpit — per-site connect + LLM-first mutation API¶
Status: Proposed (revised v2) Date: 2026-06-10 Deciders: Ramón Kamibayashi + Claude Opus 4.7 Related: ADR-0010 WP-Pulse, ADR-0014 Plugin & theme manager, ADR-0015 Reversibility, ADR-0016 WP operator console
Technical story: Elementor is the dominant page builder across the WP-Pulse fleet, but every Elementor edit today still happens inside each customer's /wp-admin/. Sites are independent of each other (each one is a different client), so this ADR is not about linking them — it is about turning WP-Pulse into the fastest, most intuitive way to connect to any single site and edit its Elementor content, both for the human operator and for an agentic LLM driving UI changes through a typed API. Per-site fluency is the goal. Cross-site features (brand kit sync, shared template library) are explicitly out of the primary path and parked in an optional appendix at the end of this ADR.
Context¶
What "easy and intuitive to connect" should look like
- Open the site detail page in WP-Pulse, click Open Elementor session — one click — and land in a split view: live preview iframe on the left, addressable element tree + settings panel on the right.
- Click any element in the preview → it's highlighted in the tree, settings open, edits are live with sub-second feedback.
- No round trip through
/wp-admin/. No re-authentication. No tab juggling. - The session is already authorised — WP-Pulse already owns the preview stack and the bundle token (ADR-0010 §F), so connection is free.
What "an LLM agent can use very efficiently" should mean
- The agent fetches one manifest endpoint and receives everything needed to be productive: API grammar, the site's widget registry, JSON schemas for every widget type's settings, the page index, selector syntax, examples. Zero exploration overhead.
- Mutations are typed verbs (
set,insert,move,clone,delete,wrap,replace), not raw JSON poking. Each call is idempotent with explicit element identity. - Selectors are stable across edits (
eid:abc123survives parent reordering;widget:heading[contains-text="Inicio"]resolves semantically). - Mutations can be batched transactionally — all-or-nothing — so a multi-step UI change is one call, one rollback unit.
- Every mutation is previewed, reversible, and audit-logged through the existing pending-changes contract (ADR-0015). Discard reverts cleanly.
- The agent gets rich, actionable errors (
{code, message, hint, suggested_fix}) so it can self-correct without operator help. - The agent can optionally request a screenshot with any mutation to visually verify its work.
What hurts today that this fixes
- The external Claude agent (ADR-0010 §F) has wp-cli access but no safe way to touch Elementor — raw
_elementor_datamutations corrupt serialized data, miss CSS regen, and have no inverse. It has to guess at JSON shapes. - The operator's Elementor workflow is wp-admin-bound. Even just "change this heading text" requires wp-admin → page → edit with Elementor → wait for editor → click → save → wait. WP-Pulse can do it in one POST.
- There is no addressable model of the page tree outside of Elementor's editor. We can't programmatically say "the third button in the contact section" — we have to think in serialized-JSON paths.
- CSS cache flushes are silent operator overhead after every meaningful edit.
- Undo lives only inside Elementor's editor session; once the operator closes the tab, recovery is via WP revisions only (coarse, full-page).
Why WP-Pulse is the right place to solve this
- Per-site preview stacks already exist → every mutation is preview-first.
- Pending-changes + signed prod replay already exist → mutations are reversible and promoted explicitly.
- The bundle token + agent handoff already exist → the LLM channel is already open.
- The worktree + git layer already exist → Elementor data can be diffed.
- Nobody else combines these primitives.
Decision¶
Ship an Elementor cockpit subsystem with two equally important surfaces, both per-site:
- Operator surface —
/sites/[slug]/elementor/route group: an embedded preview iframe + element tree + settings panel + mutation log. One-click connect from the SiteHero. - Agent surface —
/api/v1/sites/{slug}/elementor/*HTTP API + thepulse elementor *wp-cli toolkit exposed through the bundle token. Designed manifest-first, typed-verb, idempotent.
The two surfaces are isomorphic: they speak the same mutation grammar, against the same element addressing model. Anything the operator can click, the agent can call, and vice versa. The operator UI literally calls the same endpoints the agent does.
Both feed every mutation into pending_db_changes with inverse capture (ADR-0015) so discard reverts cleanly and apply-to-prod replays via signed wpcli.
Cross-site features (brand kit sync, shared template library) are deferred to Phase B and described separately at the end of this ADR. They are valuable but orthogonal to the primary goal.
Phase A — primary work (per-site cockpit + LLM API)¶
| Phase | Title | Headline outcome |
|---|---|---|
| F1 | Connect & inspect | One-click split-view session: preview iframe ↔ element tree ↔ settings panel. |
| F2 | Element addressing + selectors | Stable element IDs and a CSS-like selector grammar that both UI and agents use. |
| F3 | Mutation grammar (typed verbs) | set, insert, move, clone, delete, wrap, replace, bulk. Transactional. |
| F4 | Manifest & widget schema (LLM-first) | One endpoint hands the agent everything: capabilities + schemas + site context. |
| F5 | Live feedback (screenshots + events) | Auto before/after screenshots; SSE event stream for live agent observation. |
| F6 | Sessions, undo, snapshots | Named save points; per-mutation undo; session-scoped discard. |
| F7 | Agent ergonomics | Rich errors, self-test endpoint, idempotency keys, streaming. |
F1 — Connect & inspect¶
What "connect" means in UX¶
On /sites/[slug] the SiteHero gets a new primary action: Open Elementor session. Clicking it opens /sites/[slug]/elementor/session — a full-screen split view:
┌──────────────────────────────────┬───────────────────────────┐
│ Live preview │ Page tree │
│ (iframe of preview site) │ ▾ Inicio (page:12) │
│ │ ▾ section:0 Hero │
│ (click any element to focus) │ ▾ column:0 │
│ │ widget:heading │
│ │ widget:text │
│ │ ▸ column:1 │
│ │ ▸ section:1 Servicios│
│ │ │
│ ├───────────────────────────┤
│ │ Settings (focused) │
│ │ heading: │
│ │ ├ title: "Inicio" │
│ │ ├ size: h1 │
│ │ └ align: center │
└──────────────────────────────────┴───────────────────────────┘
The iframe loads the preview site with a thin overlay script (injected by WP-Pulse, not Elementor) that:
- Highlights the element under the cursor.
- Sends a postMessage({type: "focus", eid}) to the parent on click.
- Receives {type: "highlight", eid} from the parent to flash an element when selected from the tree.
The overlay script is served by WP-Pulse from /preview-overlay.js and injected by a per-site nginx snippet only when the request comes through the WP-Pulse iframe (referer check + signed query param). It does not modify Elementor's own editor.
What "inspect" means¶
The page tree is built from the worktree's _elementor/pages/{id}.json mirror (see F3 in original draft, retained here as a sub-piece of F2 — the JSON mirror is foundational). Tree nodes show:
- Kind (section, column, widget:heading, etc.)
- Optional label (the widget's primary visible text, truncated)
- Stable eid
- Inline action menu (edit, clone, move up/down, delete, wrap)
Settings panel renders dynamically from the widget schema for the focused element (see F4). Number inputs, color pickers, font pickers, alignment toggles are generated from JSON Schema, not hand-built per widget.
Why this matters¶
The operator never leaves WP-Pulse for normal edits. The "feel" is closer to Webflow or Framer than to wp-admin. And — critically — every click in this UI is a call to the same endpoints an LLM uses, which means the agent can replay anything the operator does and vice versa.
F2 — Element addressing + selector grammar¶
The addressing model¶
Every Elementor element gets a stable WP-Pulse identifier:
where elementor_id is Elementor's own internal _id (a 7-char hex string Elementor already assigns to every element). We don't invent new IDs — we surface what Elementor already maintains. This means an eid survives reordering, parent moves, and unrelated edits.
Pages themselves are addressed as page:{post_id}.
The _elementor git mirror¶
After every pull and every preview mutation, _elementor_data is serialized to _elementor/pages/{post_id}-{slug}.json (pretty-printed, sorted keys). This makes git diff meaningful and gives the cockpit a fast read path that doesn't hit the DB on every render. A .elementor-mirror.json lockfile maps post_id → sha256 to detect drift between disk and DB.
A property-based test suite ensures read → write → read is idempotent (no field reordering, no numeric/string coercion bugs).
The selector grammar¶
For both the operator UI and the agent API:
| Selector form | Meaning |
|---|---|
eid:abc1234 |
Exactly that element. |
page:12 |
Whole page (root). |
page:12 > section:0 |
First section of page 12. |
page:12 widget:heading |
All heading widgets on page 12. |
widget:heading[title*="Inicio"] |
Heading widgets whose title contains "Inicio". Case-insensitive. |
section:has(widget:button[text="Reservar"]) |
Sections containing a button labelled "Reservar". |
page:12 .has-class("hero") |
Elementor CSS class match. |
The grammar is implemented server-side as a small parser → tree walker over the JSON mirror. No client-side magic. Selector matches return ordered lists of eids.
LLM ergonomics: every API verb accepts either a single eid or a selector string. Selector evaluation is part of the same call, so the agent doesn't round-trip "find then mutate".
F3 — Mutation grammar (typed verbs)¶
The verbs¶
| Verb | Body | Effect |
|---|---|---|
set |
{selector, patch: {settings_subset}} |
Merge patch into target's settings. Validated against widget schema. |
insert |
{parent_selector, position, kind, settings, children?} |
Insert a new element at position (first, last, before:eid, after:eid, index:n). |
move |
{selector, to: {parent_selector, position}} |
Re-parent / reorder. Cross-page allowed. |
clone |
{selector, to?: {parent_selector, position}} |
Duplicate. to optional → same parent, after source. |
delete |
{selector} |
Remove element + descendants. |
wrap |
{selector, wrapper: {kind: "section"\|"column", settings?}} |
Wrap selected element in a new container. |
replace |
{selector, with: {kind, settings, children?}} |
Swap element subtree for a new one (preserves eid where possible). |
bulk |
{ops: [<any of the above>]} |
Transactional. All succeed or all roll back. |
Idempotency¶
Every mutation call accepts an optional idempotency_key. Calls with the same key inside a 24h window return the original result without re-applying. This protects against agent retries.
Validation¶
set and insert validate the proposed settings object against the widget's JSON Schema (F4) before writing. Errors are precise: {code: "schema_violation", path: "settings.title_size", expected: "enum(default,small,medium,large,xl,xxl)", got: "huge", hint: "try \"xl\""}. This is essential for LLM self-correction.
Side effects¶
Every mutation:
- Updates the JSON mirror file in the worktree.
- Writes the new _elementor_data to the DB via wp post meta update.
- Triggers a per-post CSS flush (wp elementor flush-css --post_id=N).
- Captures inverse in pending_db_changes.before.inverse_argv per ADR-0015 §G.
- Emits an SSE event (F5).
Auto-revert on partial failure¶
bulk is wrapped in a DB transaction. If any op fails validation or write, all prior ops in the same bulk are reverted (the inverse is computed pre-flight for each op, applied in reverse order on failure). The agent sees a single rejection with the failing op's index.
F4 — Manifest & widget schema (LLM-first design)¶
The single endpoint that makes agents productive¶
Returns one bundle:
{
"site": {"slug": "hotelaldamagolf-com", "preview_url": "https://...", "elementor_version": "3.23.4", "pro_version": "3.23.0"},
"kit": {"primary_color": "#A1742C", "body_font": "Inter", ...},
"pages": [
{"id": 2, "slug": "home", "title": "Inicio", "edit_mode": "builder"},
{"id": 7, "slug": "contacto", "title": "Contacto", "edit_mode": "builder"}
],
"widget_registry": [
{"kind": "heading", "name": "Heading", "category": "basic", "icon": "...", "schema_url": "/elementor/widgets/heading/schema"},
{"kind": "button", "name": "Button", "category": "basic", "schema_url": "..."},
{"kind": "form", "name": "Form", "category": "pro", "schema_url": "..."},
{"kind": "eael-info-box", "name": "Info Box", "category": "addon:essential-addons", "schema_url": "..."}
],
"selectors_grammar_url": "/docs/elementor-selectors.md",
"verbs": ["set", "insert", "move", "clone", "delete", "wrap", "replace", "bulk"],
"examples": [
{"intent": "change a heading text", "call": {"verb": "set", "selector": "page:2 widget:heading[title*=\"Inicio\"]", "patch": {"title": "Bienvenido"}}},
{"intent": "add a button to the hero section", "call": {"verb": "insert", "parent_selector": "page:2 section:0 column:0", "position": "last", "kind": "button", "settings": {"text": "Reservar", "link": {"url": "/contacto"}}}}
],
"etag": "sha256:..."
}
One fetch, full context. Schemas are fetched lazily but cacheable indefinitely per ETag.
Widget schema endpoint¶
Returns JSON Schema for that widget's settings:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Heading",
"type": "object",
"properties": {
"title": {"type": "string", "description": "Heading text. HTML allowed."},
"header_size":{"type": "string", "enum": ["h1","h2","h3","h4","h5","h6","div","p"]},
"align": {"type": "string", "enum": ["left","center","right","justify"]},
"title_color":{"type": "string", "format": "hex-color"},
"typography": {"$ref": "#/$defs/typography"}
},
"$defs": {"typography": { ... }}
}
Schemas are derived at introspection time from Elementor's widget controls API (via wp elementor:controls dump — a small custom WP-CLI command we ship as part of the WP-Pulse companion plugin). This way addons (Essential Addons, JetEngine, etc.) get schemas for free without us hand-maintaining a registry.
Why this design is LLM-friendly¶
- Single discovery call. The agent does
GET /manifest, caches it, and is done. - Self-describing. Examples in the manifest map intent → call so the model has few-shot prompts baked in.
- Schema-validated. The agent can pre-validate its own calls before sending.
- Cacheable. ETag means re-fetches are free.
- Stable surface. Verbs don't change when Elementor adds a new widget — only
widget_registrydoes.
F5 — Live feedback (screenshots + events)¶
Per-mutation screenshots (optional)¶
Every mutation endpoint accepts:
Response includes signed URLs to PNGs captured by a headless Chromium worker running on the WP-Pulse host. Defaults to off for cost reasons; agents typically opt in only on significant mutations.
For the operator UI, the mutation log timeline auto-captures thumbnails for every action so the operator can see what they did 10 minutes ago.
SSE event stream¶
Server-sent events stream emits one event per mutation:
event: mutation
data: {"id": "...", "verb": "set", "selector": "eid:abc1234", "result": "ok", "elapsed_ms": 142, "screenshot_before": "...", "screenshot_after": "..."}
The operator UI uses this to refresh the tree without polling. An LLM agent can subscribe to "watch" a session another agent (or operator) is driving — useful for co-pilot patterns.
F6 — Sessions, undo, snapshots¶
Sessions¶
A "session" is a logical grouping of mutations against a single site. Sessions auto-open on the first mutation and auto-close after 30 minutes idle. Each session has an id, a list of mutations, and a state (open, committed, discarded).
Undo¶
Each mutation captures its inverse pre-flight (per ADR-0015 §G — already in place for agent_wpcli rows). The session exposes:
POST /sessions/{id}/undo— undoes the last mutation.POST /sessions/{id}/undo?count=N— undoes the last N.POST /sessions/{id}/redo— redoes if no new mutations after the undo cursor.
Operator UI has standard Cmd+Z / Cmd+Shift+Z bindings wired to these endpoints.
Snapshots¶
Named save points within a session:
POST /sessions/{id}/snapshotbody{name}— saves current state.POST /sessions/{id}/restorebody{snapshot}— restores. Equivalent to repeated undo to that point.
Snapshots are cheap (just markers on the mutation log — no extra data) since we already have the inverse of every mutation.
Session discard¶
POST /sessions/{id}/discard — reverts every mutation in the session (uses the existing bottom-of-modal Discard flow we just fixed in pushes.discard_changes, scoped to the session's pending_db_changes rows).
F7 — Agent ergonomics¶
Rich error model¶
Every 4xx response uses a unified shape:
{
"code": "schema_violation",
"message": "settings.title_size must be one of the allowed values",
"path": "settings.title_size",
"expected": "enum(default,small,medium,large,xl,xxl)",
"got": "huge",
"hint": "Did you mean \"xl\"?",
"suggested_fix": {"verb": "set", "selector": "eid:abc1234", "patch": {"title_size": "xl"}},
"docs_url": "https://wp-pulse.local/docs/elementor/widgets/heading"
}
The suggested_fix field is what makes this LLM-friendly: when present, the agent can retry the suggested call directly.
Self-test endpoint¶
Runs a scripted walk-through against a sandbox page: insert a heading, set its text, clone it, delete the clone, undo. Returns a step-by-step result {step, verb, ok, elapsed_ms, notes}. An LLM can call this once on first connection to confirm the surface works and to learn the response shapes by example.
Streaming for long ops¶
Bulk operations with >20 ops stream progress via Transfer-Encoding: chunked:
{"op": 0, "status": "ok"}
{"op": 1, "status": "ok"}
{"op": 2, "status": "error", "code": "schema_violation", ...}
{"summary": {"total": 3, "ok": 2, "failed": 1, "rolled_back": true}}
Idempotency keys¶
Already covered in F3. Worth restating: every write verb honors Idempotency-Key header (RFC-aligned semantics).
Rate-limit transparency¶
Responses include X-Pulse-Mutations-Remaining so the agent can self-pace. Default budget: 600 mutations/hour per bundle token. Higher for operator sessions.
Authentication¶
All endpoints under /api/v1/sites/{slug}/elementor/* are accessible via:
- Operator session cookie (PocketID OIDC) — for the WP-Pulse UI.
- Bundle bearer token (ADR-0010 §F handoff) — for external agents.
Identical surface, same authorization.
Data model deltas¶
-- Worktree mirror lockfile is on disk, not DB. No new tables for F2/F3.
-- New: mutation log per session (replaces ad-hoc tracking)
CREATE TABLE elementor_sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
site_id uuid NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
opened_by_kind text NOT NULL, -- "operator" | "agent"
opened_by_id uuid,
opened_at timestamptz DEFAULT now(),
closed_at timestamptz,
state text NOT NULL DEFAULT 'open' -- open | committed | discarded
);
CREATE TABLE elementor_mutations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
session_id uuid REFERENCES elementor_sessions(id) ON DELETE CASCADE,
pending_db_change_id uuid REFERENCES pending_db_changes(id),
verb text NOT NULL,
selector text,
body jsonb NOT NULL,
result jsonb,
inverse jsonb,
screenshot_before_url text,
screenshot_after_url text,
applied_at timestamptz DEFAULT now(),
undone_at timestamptz
);
CREATE INDEX ON elementor_mutations (session_id, applied_at);
- New
pending_db_changes.kindvalue:elementor_mutation. Inverse_argv populated per verb. Replay on prod via the same signed-wpcli path as ADR-0015. - New row on
sitesis not required — site-level Elementor state can be re-introspected on demand. We avoid adding theelementor_statecache table from the previous draft unless F1's tree-build becomes slow in practice.
Migrations: alembic 030–031.
Operator UI surface¶
/sites/[slug]/elementor/ route group:
session/— the split-view editor (F1).pages/— flat list of all pages with Elementor enabled, click → opens that page insession/?page=N.mutations/— history of mutations across all sessions, with screenshot timeline.find-replace/— AST-aware string find/replace (uses the same selector grammar +setverb under the hood).
The SiteHero gets a single new primary action: Open Elementor session →.
Why this is "muy eficiente" for LLM agents¶
Concretely, an external Claude agent can complete a meaningful UI change in 2 round trips end-to-end:
GET /elementor/manifest(cached after first call).POST /elementor/bulkwith all mutations +capture: {after: true}on the last one for visual confirmation.
For comparison, doing the same via raw wp-cli requires: introspect post → fetch _elementor_data → parse JSON → mutate → write back → flush CSS → screenshot — and each of those is a separate wp_cli invocation with serialization overhead. Conservatively a 10x reduction in latency and token spend for the agent.
Specifics that drive efficiency:
- One manifest fetch replaces dozens of exploratory wp-cli calls.
- Schema-validated calls mean the agent retries less often.
- Idempotency keys mean network errors don't cause double-writes.
- Bulk + transactional means one call replaces N.
- Selector grammar means "find then mutate" is one step, not two.
- Stable
eids mean agent-side context (caching "the hero button is eid:abc1234") stays valid across edits. - Rich errors with
suggested_fixmean self-correction is one extra call, not a debug loop.
Phase B — optional, deferred (cross-site features)¶
These were the bulk of the previous draft. They are deprioritised because sites are independent customers, not a shared brand. If the operator one day onboards multi-site clients (an agency, a franchise), Phase B becomes relevant. Until then, F1–F7 deliver the actual ask.
Parked items:
- Cross-site Brand Kit sync (per fleet-app canonical kit, bulk push).
- Cross-site Template Library (versioned, parameterized, deploy-to-N).
- Fleet-wide Pro update cockpit (canary, visual regression gate, auto-rollback).
- Inventory matrix at /fleet/elementor (Elementor versions across all sites at a glance).
When/if these get unlocked, they reuse everything from Phase A — same selector grammar, same verbs, same manifest, same mutation log. They become orchestration layers on top.
Consequences¶
Positive¶
- Operator's Elementor workflow stops requiring
/wp-admin/for routine edits. - The external Claude agent gets a safe, ergonomic, efficient Elementor surface — currently the single biggest gap in agent productivity on WP-Pulse.
- Mutation history becomes queryable, undoable, snapshottable per site.
- The
_elementorgit mirror retroactively makes every site's history readable. - The same API drives both the human UI and the agent — half the maintenance, twice the validation surface.
Negative / costs¶
- Widget schema derivation depends on a small custom WP-CLI extension shipping with the WP-Pulse companion plugin — not zero effort, but contained.
- Preview iframe overlay script needs careful CSP / referer handling so it never injects in non-preview contexts.
- Selector grammar parser is a small but real subsystem; ~300 LOC + tests.
- Schema validation requires keeping up with Elementor's own widget control changes between minor versions. Mitigated by the schema being derived at introspection time, not hardcoded.
Out of scope¶
- Building a from-scratch Elementor editor inside WP-Pulse (we use Elementor's serialization format and CSS pipeline; we don't reimplement the editor canvas).
- Replacing Elementor Pro features.
- Anything multi-site (Phase B).
Acceptance criteria¶
ADR is "delivered" when:
- F1: clicking Open Elementor session lands on the split view with iframe + tree + settings panel functional.
- F2: a request to
seta widget byeid:AND by selector (widget:heading[title*="Inicio"]) both succeed against the same target. - F3: a
bulkop with 5 mixed verbs commits atomically; introducing one invalid op rolls back the whole bundle. - F4:
GET /elementor/manifestreturns widget registry covering core + Pro + at least one addon; schema endpoint returns valid JSON Schema for theheadingwidget. - F5: a mutation with
capture: {after: true}returns a usable PNG URL within 5s. - F6: an operator can undo / redo the last mutation via Cmd+Z / Cmd+Shift+Z; session discard reverts everything.
- F7: a forced schema violation returns a
suggested_fix; the self-check endpoint runs end-to-end green. - Bonus: an external Claude agent, given only the bundle token + manifest URL, completes "change the hero heading on home to 'Bienvenido'" in a single bulk call without any operator intervention.
Open questions¶
- Overlay script delivery. Inject via per-site nginx snippet on the preview container, or via the WP-Pulse companion plugin? Plugin route gives us versioning + uninstall hygiene; nginx route is simpler. Lean nginx.
- Eid stripping in git mirror. Elementor's
_idfield is stable, but auto-generated for new elements at save time. To keep diffs minimal we may want to canonicalize / sort children deterministically. Trade-off: lose insertion order signal in diffs. - Schema source. Derive from
wp elementor:controls dump(requires our WP-CLI extension) vs. parse PHP files inwp-content/plugins/elementor/. The wp-cli route handles addons for free; the PHP route is brittle. Lean wp-cli. - Operator session vs agent session sharing. If the operator opens a session while an agent has one open, do we merge mutation logs or fork? Lean: same session if same
site_id+ open window <30m; otherwise fork. - Rate-limit budget. 600 mutations/hour is a guess. Will adjust after first production usage. Per-token override available from day one.
Drafted in-session 2026-06-10. Replaces the v1 fleet-oriented draft — refocused per operator feedback: sites are independent, the real wins are per-site connection UX + LLM-grade API.