Support ticket: a WordPress install under /ecubo/ connected fine and held a
valid gateway token, but describe-site returned tool_count 0 and the site's own
MCP log stayed empty, because the gateway never reached it.
EMCP Cloud derived the site address from the ORIGIN of the redirect_uri, and
URL.origin is scheme + host + port by definition, so the subdirectory was
discarded. The site was registered as the bare domain and the gateway called
https://host/wp-json/mcp/emcp-tools-server, which is not the install. The
customer spotted it themselves: Connected Sites showed the domain with no path.
The Cloud fix (deployed) derives the base from the callback instead, which
repairs existing installs without a plugin update. This adds the authoritative
half: the site reports its own base in the connect state, so the value follows
an admin's Server URL override. Nothing on the Cloud side can know about that
override, since it is not reflected in admin_url().
The Cloud trusts the reported base for its PATH only. State travels through
the browser, so the host keeps coming from the redirect_uri, which is where
the OAuth flow actually returns to.
Verified end to end with the real emitted state: a simulated /ecubo install
produces base https://pruebalaweb.com/ecubo, which composes to
https://pruebalaweb.com/ecubo/wp-json/mcp/emcp-tools-server.
Co-Authored-By: Claude Opus 5 <[email protected]>
Prompted by a support ticket where a client kept failing with "the return
address this app asked for does not match the one it registered". The server
was right to refuse (RFC 8252 relaxes the PORT for a loopback redirect, never
the path), but the site owner had no way to see the stale registration, let
alone clear it, and nothing on the client side would drop it either.
Connected apps listed only clients holding a live token, because
list_authorized_clients() INNER JOINs the token table. Its own copy admitted
it: "An app that has registered but not finished signing in is not listed."
And Revoke deleted tokens while leaving the registration, which then survived
gc() forever because authorized_at was set.
- list_clients() LEFT JOINs, so a registration with no live token appears.
Connected rows sort first so the useful ones stay on page 1.
- client_state() names the three cases: connected, signed out (authorized once,
can sign back in), never signed in.
- delete_client() removes the registration and its tokens together. Tokens
first: bearer validation reads the token table alone, so one outliving its
client row would keep working.
- The panel shows each registration's callback under its name, which is the
field someone comes here to compare against a refused authorization, plus
status and registration date. Sign out (when connected) and Remove
(confirm-gated), both manage_options with a per-client nonce. Paged at 20.
Also fixes a silent action: revoke redirected with oauth_revoked=1 but nothing
rendered it, so signing an app out appeared to do nothing. All three outcomes
now show a notice.
Live-verified on msrplugins.test with three seeded registrations: cancelling
the confirm changed nothing, accepting removed the row, and Sign out flipped a
connected app to Signed out while keeping its registration.
Co-Authored-By: Claude Opus 5 <[email protected]>
All three Sandbox screens rendered every artifact in one table, and each row
carries that artifact's source: the snippets table ships every snippet's whole
PHP into the DOM, widgets ship compiled PHP, blocks ship block.json plus
render.php. With 26 snippets the page carried 26 code blocks; paged, page two
carries six.
The stores also asked for a flat posts_per_page 200 with no_found_rows, so a
site past its 200th artifact lost the rest with nothing on screen saying so.
Two bugs a live smoke caught on real data, neither visible to php -l or to a
stubbed test:
- ORDER BY post_modified is not a total order. 26 seeded snippets shared two
timestamps, and across two pages one row appeared twice while another was
unreachable: 25 of 26 visible. Fixed with an ID tiebreaker, re-measured at
26 of 26.
- The out-of-range clamp was dead code. WP_Query::set_found_posts() bails when
a page comes back empty, so such a page reports found_posts = 0 and a clamp
testing "total > 0" can never fire in the case it was written for. Confirmed
against the database: LIMIT 490,5 on a one-row table returns 0. It now
re-queries page 1, which does carry the total, then lands on the last real
page.
- class-sandbox-list-query.php: one paged query for all three artifact post
types, returning a page of IDs plus the real total.
- class-admin-pager.php: the windowed pager, lifted out of page-marketplace.php
rather than copied three more times. Marketplace now renders through it too,
so there is one pager to style. An ellipsis no longer stands in for a single
page, which the inherited version could do.
- The three stores gain list_*_page(); the flat list_*() are untouched, since
the MCP tools want the whole list in one call.
- Each view shows "Showing 1-20 of 57" above the table and the pager below.
Live-verified on msrplugins.test: page 1 and page 2 render with working links,
row actions and code blocks intact, Prev linking back to the clean URL, and the
Marketplace unchanged at 36 results across 2 pages.
Co-Authored-By: Claude Opus 5 <[email protected]>
Seven findings, all verified against the code first. None is reachable
by an anonymous visitor: each needs an administrator, or a URL an
administrator already asked the plugin to fetch.
F-01, the one that mattered. safe_download() checked the URL you gave it
with the strong guard, then handed redirects to WP_Http with
reject_unsafe_urls, which only applies core's weaker check. The file's
own comment already said core does not reject 169.254.0.0/16, so a URL
could pass and then bounce to the cloud metadata service, with the
response written into the Media Library. Redirects are now followed by
hand with the full check on every hop. The address check itself also
read only the first A record, so a host publishing one public and one
internal address passed: host_is_blocked() now requires every answer,
A and AAAA, to be public, and fails closed when a host resolves to
nothing. Rebinding is still out of scope here and the docblock says so.
F-02. search-files judged and read each hit by the path it walked, which
for a symlink is the link. Its basename defeated the secret check and
reading it followed the link out of ABSPATH, so a link named notes.txt
pointing at wp-config.php answered a search for DB_PASSWORD. Every hit
now goes through resolve_path() first, which is what read-file already
did. Listings label symlinks instead of presenting them as files.
F-04. The read-protected list was one exact filename. Now covers .env,
wp-config.* copies, .htpasswd, auth.json, key files, and anything under
.git. .htaccess stays readable on purpose: it is not a secret.
F-03. The proxy disabled certificate verification for any host ending
in .dev, which is a real gTLD anyone can register, while sending an
Application Password on every request. Removed; EMCP_INSECURE_TLS=1 is
the opt-in. Package to 1.11.0 since it is published.
F-05. Two sandbox guards used wp_normalize_path(), which does not
resolve "..", so the containment check they exist to make did not hold.
Both resolve properly now. Defense in depth: the hash check beside them
is what actually held.
F-06. The update package URL is now asserted to be a release asset of
this repository before the upgrader is handed it.
F-07. The snippet validator skipped every method call, so a function on
its critical list reached as $file->fwrite() was never looked up.
Reported as a warning rather than critical, because a name alone does
not identify the object and falsely blocking someone's $wpdb->query()
is worse than a line a reviewer reads.
One correction to the report: validate_pinned() is not "called from
nowhere". The Pro AI Chat fetcher uses it per hop with CURLOPT_RESOLVE
pinning; the scan could not see pro/.
Live-verified on msrplugins.test: the six address cases behave, a real
119KB download still completes through the rewritten loop, redirect
targets to metadata and RFC1918 are refused, and a refused download
leaves no temp file behind. Suites: pro 2541, public 155, proxy 14.
The old Cloud card was three plain bullets and a link, which is why the
artwork existed in the first place. This rebuilds it properly rather than
shipping the artwork: badge with its cloud mark, a two-line headline with
the second line in indigo, the description, three benefit rows each with
a circled icon, a title and a supporting line, dividers between them, and
a full-width call to action. Same dark base and inline SVG as the Pro
card beside it, so the column reads as one thing.
Markup rather than the image because of the column it lives in. The
artwork is 1519px wide and its body text is about 24px there, so at this
~380px width it rendered around 6px: readable as shape, not as sentences.
Markup reflows instead of shrinking, keeps the type sharp at any width,
and keeps the copy translatable. The artwork is now in Promos, where the
rest of the launch collateral lives.
Both cards also grow to fill the column again, so it ends level with the
toolkit panel rather than stopping short. Their content is centred as a
group instead of pinned top and bottom, which is what left a tall empty
band through the middle of the Pro card last time.
Two things caught while checking:
- The full cloud artwork's three connected nodes landed on the
description and the first benefit row. The decoration is cropped to
the cloud and its layers, sized to finish above the description.
- The call to action overflowed the card by 28px and was being clipped.
width:100% is measured before the button's own side padding, and a
flex column already stretches its children, so the width was both
redundant and wrong.
Verified geometrically: nothing clips, the decoration clears the copy,
and the column is flush with the toolkit.
The Cloud promo was a card assembled from markup: badge, headline, three
benefits and a button. The artwork says all of that already, so it takes
the card's place in the sidebar rather than being rebuilt beside it. The
link wraps the image and the alt text carries the same message for anyone
who cannot see it.
That left the flat violet Pro card sitting under a dark indigo banner,
looking like it belonged to a different plugin, so it is now dark too:
an indigo-on-navy gradient, an indigo badge, chip and button, and inline
SVG decoration. Inline rather than an image, so it stays crisp at any
column width and the copy stays translatable.
Three things fixed while looking at it:
- A decoration path pinned top-right at 220x160 crossed the body copy.
- Both cards grew to fill the column height. That was right when they
were two stretchy cards; with a fixed-ratio image above it, the Pro
card grew into a tall empty band. It sizes to its content now.
- The Upgrade button painted violet text on the indigo fill: a leftover
colour override from the old white button, sitting after the new rules
and winning the cascade. Only the free build renders that button, so
it would not have shown up on a Pro site.
Verified both states at 1801px and at 2578px wide.
Note on legibility: the artwork is 1519px wide and its body text is about
24px there, so in this ~378px column it renders around 6px. Readable as a
block of shape and colour, not as sentences. Widening the sidebar from 25
to 30 would help if that matters.
Version to 3.14.0. Changelog closed out with the BeTheme integration and
the settings-saved toast, which had landed without entries.
readme.txt also gains the 3.13.2 section, which was missed at the time:
the stable tag was bumped but the changelog still started at 3.13.1.
The confirmation was rendered as an ordinary notice at the top of the
page and lifted into the corner by admin.js, so the browser painted it
top-left first and jumped it bottom-right a frame later. Hiding it until
the script had moved it would have fixed the flash by making the
confirmation depend on the script running at all.
PHP now renders the finished toast, so the first paint is already
bottom-right and nothing moves afterwards. admin.js only adds the
dismissing: close button, auto-hide, and a pause while it is read. If
the script never runs the toast stays until the next page load, which is
the right way round for a confirmation.
Version to 3.14.0-rc4 so the test site gets the new CSS and JS: with
WP_DEBUG off the assets are cache-busted by plugin version alone.
On jQuery ready WordPress runs
$( 'div.notice' ).not( '.inline, .below-h2' ).insertAfter( $headerEnd )
which relocates non-inline notices to just under the page h1. Our JS had already
moved this one into the toast, so core pulled it straight back and left an empty
toast at the corner with the text still at the top.
Marking the notice `inline` opts out of that relocation entirely, which beats
trying to run after it. It also degrades better: if the JS never runs the notice
renders in place rather than disappearing.
Co-Authored-By: Claude Opus 5 <[email protected]>
WordPress puts it in a stack above the page, which shifts the whole screen down
at the moment you have finished with the top of it and are looking at what you
changed. It now appears bottom right, fades after 5 seconds, and pauses if you
hover or focus it.
Only this one notice moves. Everything else stays put: page-history's rollback
conflict warning carries a "Roll back anyway" button, and a decision you have to
act on does not belong in a corner.
Bumped to rc2 because assets are cache-busted by plugin version and WP_DEBUG is
off on the test site, so syncing the files without a version change left the
browser serving the cached rc1 admin.js, which predates this.
Co-Authored-By: Claude Opus 5 <[email protected]>
A release candidate rather than a file copy onto the test site, so the test
exercises the shipping artifact: whether the Pro overlay actually places the
integration, and whether pro-manifest.txt and the Pro loader's file list agree.
Those are two separate lists, and 3.13.0 shipped a file that was in one and not
the other with a class_exists guard hiding it.
Also adds the BeTheme conflict notice for EMCP Themer and the Tools-screen
badge work to the 3.14.0 notes.
Co-Authored-By: Claude Opus 5 <[email protected]>
Reported from a live site: the Elementor Addons cards showed a greyed-out
toggle, and it took a moment to work out that this meant a missing third-party
plugin rather than a bug or a licensing limit. The explanatory line existed,
but at a glance a disabled toggle reads as broken or not-in-my-plan.
Cards now carry a badge naming the dependency ("Needs Essential Addons for
Elementor"). That needed the requirement as data rather than prose, so 53
hand-written notes became `requires => { name, kind }`. It also collapses 27
translatable strings into 2 and lets a theme dependency read differently from
a plugin one, which the single old phrasing conflated.
All 50 currently-unavailable tools carry a badge, not only the three reported.
The UAE write tool keeps its hand-written note, which varies by which UAE
plugin is present, but records the requirement too: without it, it was the one
unavailable tool on the screen with a note and no badge, sitting directly under
uae-read which had both.
The install shortcut the reporter suggested is deliberately not here. It needs
a verified wordpress.org slug per plugin, and the slugs are where this goes
wrong (UAE Pro is `ultimate-elementor`, the free one is still
`header-footer-elementor`), while several dependencies are not on
wordpress.org at all, so a search link would send people to unrelated results.
Held for 3.14.0 alongside the coming integrations, so the version is unchanged
and the notes sit under an Unreleased heading.
Co-Authored-By: Claude Opus 5 <[email protected]>
Fixes#121: editing the text of an atomic element that contains an inline tag
emptied its rich-text node tree. An html-v3 value stores the same text twice,
as markup and as a tree the editor renders from, and the wrapper always wrote
an empty tree. The page still rendered and the tool reported success, so the
only symptom was the element opening empty in the editor.
Both halves now come from one parse of the markup, with any generated id
written back before the markup is re-serialized, so they cannot drift. Ported
from Elementor's own parseHtmlChildren rather than from its description: a
node's content is the whole subtree's trimmed text, the content and children
keys are omitted when empty, and non-inline elements are descended through
rather than emitted. Three paths could flatten the tree and all three are
closed.
libxml decodes as ISO-8859-1, so input is numeric-entity encoded and decoded
on the way out; without that any non-ASCII text is mangled. Dom\HTMLDocument
would be tidier but is PHP 8.4+ and the floor is 8.1.
The snippet validator gains a third severity. Everything used to be a warning,
so a snippet built from ordinary hook code arrived covered in them, and a
reviewer told that ten routine things are warnings learns to skim. The
`definition` rule was the worst offender: its own message is about
redeclaration, which only a named function risks, yet every closure tripped
it. Messages now say what to check, and one summary() decides the verdict so
the Sandbox and PHP Templates screens cannot describe the same report
differently. The PHP Templates screen no longer paints a single routine note
as a red error.
The add path still strips inline tags. Relaxing it is what would let
add-atomic-widget author rich text, but it widens what an agent can write into
a page, so it is a separate decision.
Co-Authored-By: Claude Opus 5 <[email protected]>
Saving an Elementor document could recurse until PHP exhausted memory.
The indexer reads a document through EMCP_Tools_Data::get_page_data(), which
calls Elementor's Document::get_elements_data(). In edit mode, on a document
that was never converted, Elementor converts it, and converting saves the post.
That save fires save_post, which re-enters on_save_post(), which reads the
document again. The reporter observed 156,054 stack frames, a 512 MB memory
exhaustion, and a 22 MB nginx error log from two requests.
A private static re-entrancy guard makes the nested call return immediately,
released in a finally so an exception cannot leave indexing stuck off.
Fixes#119. Reported by @HopeItBuilds.
Reported hours after 3.13.0 shipped: putting a dynamic value on a standard
Heading produced "There has been a critical error on this website".
Elementor's tag registry keys by get_name() but stores only the class, and
rebuilds the tag later from that class and the element data alone:
$this->tags_info[ $tag->get_name() ] = [ 'class' => get_class( $tag ), … ];
return new $tag_class( [ 'settings' => …, 'id' => … ] );
3.13.0 registered one parameterised class under seventeen names, taking the
source key as its first constructor argument. Registration and the picker both
looked correct, because those use the stored instance. Rendering does not: it
rebuilds, the tag name is gone by then, and the data array landed in the key
parameter, so every render threw a TypeError.
Each source is now its own concrete class with the key in its class identity,
which is how Elementor Pro's own tags are built. The base is abstract so it can
never be registered, and registration skips (and in debug logs) any catalogued
source with no class rather than registering something unrebuildable.
Nothing stored is damaged: affected pages render correctly on update with
nothing to redo. The block editor was never affected, its bindings use our own
callback rather than Elementor's registry, verified while fixing this.
Verified live on Elementor 4.2.2: all 17 tags rebuild, each has its own class,
and a real HTTP request to a page with a dynamic Heading returns 200 with the
resolved value instead of a critical error.
Co-Authored-By: Claude Opus 5 <[email protected]>
The WordPress tab had grown to 21 sections, roughly half of which were not
WordPress management at all but features this plugin implements itself: the
theme builder, backup and migrate, redirects, the change ledger, the search
index, the content mirror, the snippet sandbox, project memory, cloud sync.
Thirteen sections move to a new EMCP Modules tab, leaving the WordPress tab
to mean tools that drive WordPress APIs and nothing else.
Three sections are new, filling the same gap the Themer tools had. The seven
Cloud and Marketplace tools registered and worked but were catalogued
nowhere, so they could not be toggled and no count included them. Resize
Media moves out of Stock & Media into an Image Optimization section of its
own: it is that module's only MCP tool and only registers when the module is
on, so listing it beside the stock-photo tools left a dead toggle on the
WordPress tab for anyone not using it.
After this the only uncatalogued abilities are the three compact-mode
meta-tools, which are excluded by design. Catalogued goes 260 to 267.
Nothing changed about what any tool does, its permissions, or whether it is
enabled by default.
Co-Authored-By: Claude Opus 5 <[email protected]>
The nine Themer tools have registered and worked since 3.1.0 but had no entry
in the admin catalog. Three consequences: an admin could not see them, could
not switch one off on its own, and get_available_tool_slugs() omitted them, so
every count the plugin and the website report was short by nine (251 to 260,
free 193 to 202).
They now sit in a dedicated section on the WordPress tab, module-gated exactly
like Themer PHP Templates, so turning the module off removes the section
rather than leaving dead toggles. All nine stay enabled by default: the
Modules tab toggle remains the single kill switch for the feature, and
per-tool defaults would give it a second, competing off switch.
themer_tool_slugs() joins the drift-guard exclusion list, since the tools are
legitimately absent whenever the module is off.
Co-Authored-By: Claude Opus 5 <[email protected]>
class-themer-dynamic-pro.php shipped via pro-manifest.txt but was never in
the loader's file list, so Themer_Pro::init()'s class_exists guard skipped it
and all seven Pro dynamic sources were absent at runtime.
Co-Authored-By: Claude Opus 5 <[email protected]>
The EMCP Themer category landed near the bottom of the panel, under
everything else, which is the wrong place when the whole point of the screen
is to build a template. It now sits directly under Favorites while one of our
templates is the open document, and is left exactly where it was everywhere
else.
Elementor offers no supported seam for this. add_category() only appends, and
Elementor says so in its own source: "Not using the `add_category` because it
doesn't allow 3rd party to inject a category on top the others." Both
$categories and the promote_category_after() helper are private, and the
elementor/document/config filter runs through array_replace_recursive(), which
preserves the original key order. So the move is done by reflection, wrapped
to fail into today's behaviour: if Elementor renames the property the category
simply stays where add_category() put it, which is cosmetic, never fatal.
The widgets themselves stay registered everywhere, deliberately. A sitemap,
search form or posts grid is useful outside a template, and Elementor Pro
likewise keeps its theme widgets in the panel on every screen.
Live-verified: promoted to index 1 on a theme template, untouched at index 13
on a page and with no document at all, and all 19 widgets still register.
Co-Authored-By: Claude Opus 5 <[email protected]>
Widget and block coverage are different sets: five sources ship a widget
with no block counterpart. Deriving one from the other advertised five
blocks that do not exist.
Co-Authored-By: Claude Opus 5 <[email protected]>
Layout and spacing only. Colour and typography stay with the theme and with
Elementor's own controls, so a widget never fights the site design.
Co-Authored-By: Claude Opus 5 <[email protected]>
The two blocks are the only new elements with no core equivalent. Comments,
search and post navigation already ship as core blocks, so duplicating them
would add maintenance for no gain.
Live verification caught a real bug while checking them: featured-image and
post-excerpt had shipped as widgets and blocks with a working value() but no
render() case, so both rendered blank everywhere. Values and markup are
separate paths and each needs its own dispatch. Both now compose their markup
on top of value(), verified live with a real thumbnail and a real excerpt.
Co-Authored-By: Claude Opus 5 <[email protected]>
Author box, post navigation, comments, search form, sitemap, post info and
archive posts. They extend a second base rather than the thin one, because
their controls are bespoke, and they render through the shared dispatcher so a
widget and its block cannot diverge.
Live verification caught a real panel bug: archive-loop and archive-posts both
showed as "Archive Posts", so a user saw two identical entries with no way to
tell them apart. The superseded one is now "Archive Posts (classic)". Labels
are not render output, so parity still holds.
Elementor now lists 19 EMCP widgets, all uniquely named, all under the one
EMCP Themer category.
Co-Authored-By: Claude Opus 5 <[email protected]>
Both succeed an existing widget rather than replacing it. Post Meta and Archive
Loop markup is already in saved templates, so changing them would alter live
sites; the parity test proves neither moved.
Post info renders an ordered list of metadata items, order controlled by the
caller, with unknown items skipped rather than rendered broken.
Archive posts adds the pagination Archive Loop lacks. It READS the main query
without iterating it, because the surrounding Themer template is still using
that query, and a test pins that the query position is unchanged afterwards.
Co-Authored-By: Claude Opus 5 <[email protected]>
The four remaining rich elements, all following the author box shape: guard
first, accumulate parts, and render nothing rather than an empty container.
Post navigation carries in_same_term, the control that makes navigation useful
on a site with several content streams, and renders one side when a post has
only one neighbour.
Post comments invokes the theme's own template rather than restyling it, and
hides itself on a closed thread by default.
Search form is built by hand rather than via get_search_form(), because the
theme version cannot be configured. It is the one element that needs no post,
since a 404 is exactly where it matters. The input is labelled and an icon-only
button carries an accessible name.
Sitemap bounds every query at 100 items whatever the caller asks for. An
unbounded sitemap is a real hazard on a large site and a footer widget is where
it would go unnoticed.
Live-verified: all five render on a real post and all five are refused as field
bindings, because they emit markup.
Co-Authored-By: Claude Opus 5 <[email protected]>
First of the rich elements, and the shape the other four follow. Every part is
toggleable and defaults to shown, an unknown name tag falls back rather than
reaching the markup, the avatar size is clamped, and an author with nothing to
show renders nothing at all instead of an empty box.
Live-verified: renders on a real post, is catalogued, and is refused as a field
binding because it emits markup.
Co-Authored-By: Claude Opus 5 <[email protected]>
Both already had a working value from the dynamic data release but no widget or
block, so they were sources an agent could bind and a human could not place.
The element base is the shared, builder-agnostic seam the richer widgets build
on next, and it delegates post resolution to the provider so elements and
dynamic sources cannot disagree about which post they are on.
Co-Authored-By: Claude Opus 5 <[email protected]>
list-dynamic-sources hardcoded "block, widget" for every source, so the five
added in this release (post-excerpt, post-url, post-date, post-id,
featured-image) advertised a block and a widget that were never registered. An
agent reading the catalog would try to add a post-excerpt block and fail.
The catalog now declares which keys ship a rendered element, and discovery
derives surfaces from that. Found by self-review while planning the widget
library, which is the work that will give those five real widgets.
Co-Authored-By: Claude Opus 5 <[email protected]>
Leads with what the reader gets rather than how it is built, and states the
part that matters most: this works on free Elementor, because Elementor ships
the dynamic-field machinery but none of the sources.
Co-Authored-By: Claude Opus 5 <[email protected]>
Themer templates can now bind any builder field to live request data. The
provider became value-first and feeds five surfaces from one catalog: the
existing blocks and widgets, Elementor dynamic tags, a WordPress block binding
source, and the MCP tools.
Verified on a genuinely Pro-free Elementor: free Elementor registers zero
dynamic tags of its own, so all 10 tags in the picker are ours, under an
EMCP Themer group. That is the premise the free tier rests on.
Co-Authored-By: Claude Opus 5 <[email protected]>
Free sources that have no value implementation now consult the
emcp_themer_dynamic_value filter before falling back, which is how the Pro
source pack attaches.
current_post_id() exposes the free provider's post resolution so Pro sources
use the SAME one, preview fallback included. The first draft of the Pro pack
had its own copy, which is exactly how two code paths start disagreeing.
custom_field() is removed from the free provider: it was implemented but never
listed in the catalog, so nothing could reach it, and it now lives in Pro.
Co-Authored-By: Claude Opus 5 <[email protected]>
add-free-widget, add-pro-widget and update-widget take a dynamic map;
add-block and update-block take a bindings map. Extending the existing tools
keeps the tool surface flat, and the static value is preserved as the fallback.
The block tools take raw markup rather than attributes, so bindings are merged
into the parsed block before the tree is serialized again.
Live-verified end to end: an agent-shaped call saved metadata.bindings, the
front end rendered the real page title in place of the placeholder, and binding
an image source into a heading was refused with an actionable message.
Co-Authored-By: Claude Opus 5 <[email protected]>
An agent cannot bind a field without knowing which sources exist and what each
produces, so this is the discovery step that precedes every binding write.
Gated with the other Themer tools: a source list is meaningless without
templates to bind it into.
Live-verified: registered in the abilities registry and returning all 10
bindable sources with their types and surfaces.
Co-Authored-By: Claude Opus 5 <[email protected]>