Skip to content

Let the AI Workspace admit tools by declaration - #1014

Open
whyisjake wants to merge 15 commits into
feat/ai-workspacefrom
feat/abilities-tool-surface
Open

whyisjake wants to merge 15 commits into
feat/ai-workspacefrom
feat/abilities-tool-surface

Conversation

@whyisjake

@whyisjake whyisjake commented Sep 10, 2026

Copy link
Copy Markdown
Member

What?

Closes #1003

The AI Workspace picks its tools from a hand-maintained list of three abilities. This lets an ability earn a place by being exposed to the surface, with the three that ship becoming a floor rather than the whole story.

Stacked on #1004 — review that one first.

Why?

The plugin's premise is that the Abilities API is the extension point and the workspace is its first conversational consumer. Today a site that registers a WooCommerce ability, a custom CPT ability, or an internal tool gets no benefit from it. #1003 has the longer argument.

How?

WordPress 7.1 added filtering to wp_get_abilities() (Trac #64990) and a unified exposure flag, so there was no primitive to build and no vocabulary to invent — only a policy to write on top of both.

Exposure follows core's precedence for every channel: meta['ai-workspace']['public'] ?? meta['public'] ?? false. An ability marked public is eligible without naming this surface, and naming it overrides either way. On top of that it must annotate readonly: true, destructive: false, open_world: false, all three present and explicit — core defaults them to null, and an absent open_world means the ability may reach outside the site, so silence is refused.

Five things worth a reviewer's attention:

The three curated abilities are a floor, not a fallback. Default-deny on its own would have left the workspace with no tools at all on 7.1. A test asserts the surface by name, because that is what a change to merge order breaks silently.

Admission is off by default. private const hides a name from callers, not from an author reading an open-source repo, and inheriting from meta.public means an ability can be eligible without naming the channel at all. wpai_workspace_tool_admission_enabled turns it on; it goes away when #354 settles the channel name.

Four core abilities are never admitted. core/get-user-info ships public, read-only and not destructive, so the only thing keeping a reader of people's personal data off the surface was an absent open_world hint — which it would be correct for core to add. The effect class asks whether an ability writes or reaches outside, not whether handing it to a model is a bad idea. wpai_workspace_withheld_abilities answers the second question for user info, users, settings and environment, and is filterable.

On WordPress 7.0 the policy does not run. wp_get_abilities() there takes no parameters and PHP discards extra arguments without a word, so asking it to filter returns everything. The probe reads the signature instead of trusting a call. The owner's off switch resolves to the same branch, so the fallback is exercised on every version rather than only the oldest.

The candidates filter may skip exposure, never the effect class. Otherwise a tool that writes reaches a model that has been told it cannot write.

Exposure and the annotations are both the author's self-attestation, written in one meta array. The effect class is declared intent, not something this code enforces. The boundaries that hold are the withheld list, the owner's controls, and the execute-time permission_callback.

Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 5
Used for: Implementation, tests, and documentation, from a plan I reviewed and scoped. A six-reviewer automated code review ran against the result and its findings are applied in 9d6d9e7b, including the default-off gate and a defect where owner removals were not enforced if the Abilities Explorer ran without the AI Workspace. gziolo's note on #354 that core already ships a unified exposure flag prompted e7ffb84 and b891df7.

Testing Instructions

  1. npm run test:php — 1844 tests.
  2. npm run test:php:multisite.
  3. With no changes, confirm Tools → AI Workspace still offers the same three abilities. Nothing differs until admission is enabled.
  4. add_filter( 'wpai_workspace_tool_admission_enabled', '__return_true' );, then register an ability with 'meta' => array( 'public' => true, 'annotations' => array( 'readonly' => true, 'destructive' => false, 'open_world' => false ) ) and confirm it appears. It never names the channel — that is the inheritance working.
  5. Confirm core/get-user-info stays off the surface with admission enabled, and that the Abilities Explorer says it is held back rather than blaming its annotations.
  6. Tools → Abilities Explorer, Exposed in column: remove an ability and confirm it leaves the assistant's surface; restore it; switch the policy off and confirm the surface returns to the three curated abilities.

Screenshots or screencast

The Abilities Explorer gains an Exposed in column showing, per ability, which surfaces hold it — REST, MCP, the assistant — plus the description text the model receives, why it is excluded, per-row remove/restore links, and a policy on/off switch above the table.

Before After
The Abilities Explorer without the Exposed in column The same rows with the Exposed in column

Every state in one view. demo/* are throwaway fixtures registered locally to
produce the eligible and refused cases; the plugin ships none of them.

Abilities showing withheld, eligible by channel, eligible by the general public flag, on surface, and refused on effect class

core/read-settings and core/read-users are held back whatever they declare.
demo/read-shipping-zones is eligible by naming the channel; demo/read-store-hours
by meta.public alone, never naming it. ai/search-content is on the surface with
its model-facing description and a remove link. demo/update-inventory is public and
MCP-exposed, and refused because it writes.

Changelog Entry

Added - The AI Workspace can admit abilities exposed to it through meta.public or the ai-workspace channel, off by default until #354 settles the channel name.

Open WordPress Playground Preview

@whyisjake
whyisjake added this pull request to stack #1015 September 10, 2026 15:10
@github-actions

Copy link
Copy Markdown

✅ WordPress Plugin Check Report

✅ Status: Passed

📊 Report

All checks passed! No errors or warnings found.


🤖 Generated by WordPress Plugin Check Action • Learn more about Plugin Check

@codecov

codecov Bot commented Sep 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.63158% with 28 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.33%. Comparing base (b22cafb) to head (56d8110).

Files with missing lines Patch % Lines
includes/Experiments/AI_Workspace/Tool_Policy.php 92.91% 9 Missing ⚠️
...s/Experiments/Abilities_Explorer/Ability_Table.php 91.46% 7 Missing ⚠️
...ncludes/Experiments/AI_Workspace/Tool_Selector.php 91.17% 6 Missing ⚠️
...udes/Experiments/Abilities_Explorer/Admin_Page.php 90.19% 5 Missing ⚠️
includes/Experiments/AI_Workspace/Turn_Runner.php 94.44% 1 Missing ⚠️
Additional details and impacted files
@@                   Coverage Diff                   @@
##             feat/ai-workspace    #1014      +/-   ##
=======================================================
+ Coverage                81.94%   82.33%   +0.39%     
- Complexity                3670     3799     +129     
=======================================================
  Files                      147      148       +1     
  Lines                    14043    14399     +356     
=======================================================
+ Hits                     11508    11856     +348     
- Misses                    2535     2543       +8     
Flag Coverage Δ
unit 82.33% <92.63%> (+0.39%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

whyisjake and others added 14 commits September 10, 2026 20:05
…ssor

WordPress 7.1 lets wp_get_abilities() filter the registry. WordPress 7.0's
takes no parameters, and PHP discards extra arguments to userland functions
without a word, so asking 7.0 to filter returns everything. A tool policy
that trusted the call would be default-allow on the plugin's own minimum.

Tool_Policy answers two questions and nothing else: whether filtered
discovery is available here, and whether an ability has declared itself fit
for a conversational surface. Both fail closed. The declaration key stays
private and provisional while issue #354 settles its public shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…loor

The workspace chose its tools from a hand-maintained list of three. An
ability can now earn a place by declaring itself fit for a conversational
surface, and the list stops being the only way in.

Default-deny alone would have emptied the surface: nothing declares yet, and
this does not retrofit the abilities that ship here. So the curated three are
a floor the policy adds to, never a fallback it replaces. A test pins that by
name, because it is the property a change to merge order breaks silently.

Two ways in are refused. Core's ability filters are site-wide and fire on
every call including ours, so a plugin hooking one could hand the model an
ability nobody declared; the query is a candidate set and every row is
re-checked here. And the candidates filter, which may skip the declaration,
may not skip the effect class -- otherwise a tool that writes reaches a model
that has been told it cannot write.

Admission requires readonly, not destructive, and not open-world, each
asserted explicitly. Core defaults all three to null, and an absent
open-world hint means the ability may reach outside the site, so silence is
refused rather than assumed. The curated floor is exempt: ai/propose-drafts
writes through the confirm gate and would otherwise strip itself out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The system instruction promised two things it had no way to know: that the
model cannot write to the site, and that a proposal tool is there to call.
Both were literals. Once the surface can grow or be narrowed, a literal is a
claim the code cannot keep.

Each sentence is now earned. The proposal paragraph appears only when the
proposal tool was declared, so the model is never pointed at a tool it does
not have. The write denial appears only while nothing declared can change the
site on its own -- ai/propose-drafts does not count against it, because it
stores values a person has to approve rather than writing them.

The warning that tool results are untrusted stays unconditional. It does not
describe the surface, it describes what to do with anything that comes back,
and that is true whatever is admitted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The policy decided the surface and nobody could see it. The Abilities
Explorer now says, per ability, whether the assistant holds it and why not
when it does not -- told apart properly, so an author who declared correctly
but writes is told that, and is not sent back to re-check a declaration that
was already right.

Six reasons, and the report enumerates the whole registry to produce them.
The admission query cannot: it only returns what already matched, so it can
never explain a non-match.

The owner can take any ability off the surface, including a curated one, and
switch the policy off entirely. Both persist as options that uninstall
already cleans by prefix. The mutation checks a nonce and manage_options,
because a control that reshapes what an assistant may call is worth a CSRF.

The removal filter is registered from the experiment bootstrap, not from a
constructor. Hooking on construction would have made the owner's removal
depend on something happening to build a policy first, so a candidate read
that did not would quietly serve a tool the owner took away. Two tests
proving removal works were relying on exactly that.

Descriptions are shown escaped. They are third-party text rendered into
wp-admin, and the point is that the owner reads the same words the model
reads -- not that wp-admin renders someone else's markup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The experiment doc still said three abilities ship in an allowlist and left
it there. Three still ship, but they are a floor now, and the interesting
half is what an ability has to do to stand beside them: declare itself, and
say plainly that it only reads, adds nothing destructive, and stays inside
the site. Two opt-ins, which is worth stating, because an author who adds
the declaration and stops is not admitted.

Also records what a reader would otherwise have to find out by trying: the
key is private until #354 settles it, so nothing changes yet; WordPress 7.0
has no filtering to do this with and gets the floor; and the Explorer is
where an owner sees the surface and takes something off it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review found the safety story was not true. The declaration key is a private
constant, and the docs said that meant no third-party ability could opt in
yet -- but private hides a name from callers, not from an author who reads
the source of an open-source plugin. Admission was live on merge.

So admission is now off by default, behind a temporary switch that goes away
when #354 settles the key's public shape. A declared ability shows in the
Explorer as eligible and waits there. The docs say this plainly instead of
claiming a barrier that was never one.

The owner's removals no longer ride on a filter the workspace bootstrap
installs. The Abilities Explorer is a separate experiment that can run while
the workspace does not, and in that configuration nothing registered the
filter -- so a removed ability was shown as held, beside a button offering to
return it. Exclusions apply inside the candidate build now, and a test pins
that without any bootstrap.

Reasons stop guessing. An ability removed by site code, dropped for its
effect class, or waiting on the gate each says so, instead of all three
arriving as "withheld by your capabilities" and sending the owner to look at
roles. A surface the owner emptied no longer reports that nothing is
registered.

The docblock claimed two independent opt-ins. They are two keys in one meta
array written by one hand. Both are self-attestation; the owner's controls
and the permission callback are the boundaries that hold.

One correction to what the last commit said: core discards a meta-mismatched
ability before wp_get_abilities_item_include fires, so that filter cannot
re-widen the query. wp_get_abilities_result can, and the re-verification loop
is what stops it. The test now proves the smuggle before proving the defense.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tool_Selector said "admission decides what the model is told exists" twice, in
two halves of the same docblock, and named wp_get_abilities_item_include as the
filter a third party could re-widen the query with. Core discards a
meta-mismatched ability before that filter fires; wp_get_abilities_result is
the one that can inject, and the one the re-verification loop is for.

The rest is wording: inverted openers straightened out, a binary contrast
stated directly, and the em dashes thinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A full tool description in every row made two rows fill the screen, so the
column meant to show what the assistant can reach was the reason the table
could not be read.

The state is a badge now, an icon beside a short word. The icon is decorative
and marked so; the word carries the meaning, because a column whose whole
purpose is being read should not depend on being seen.

The description moved into a details element, closed by default. It stays
there rather than moving to a tooltip or a title attribute: it is the exact
string handed to the model, the owner is the only person who can judge whether
it is honest, and judging it means reading and selecting long-form prose.

Two tests asserted on the old wording. They assert on the state class now, so
copy can change without a test pretending something broke, and a new one holds
the description in the cell -- collapsing it is the point, losing it is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The column asked "is this on the assistant", which made every other row a
negative and told the owner nothing. An ability can be exposed over REST, over
MCP, to the assistant, or any combination, and until now there was nowhere to
see those three answers together.

So the column is "Exposed in" and carries a badge per surface, read from the
same meta each consumer reads for itself. core/get-site-info turns out to be
REST only, ai/get-post-terms MCP only, and an ability can be eligible for the
assistant while exposed nowhere else.

That spread is the argument in issue #354. Three consumers invented three
flags, and this column is the first place the cost of that shows up as
something an owner looks at rather than something a developer reads about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… flag

WordPress 7.1 added meta.public as the general exposure flag, and a channel
resolves as meta[channel] ?? meta.public ?? the channel's own default. Core
applies that to show_in_rest at registration and writes the answer back, so
reading that key gives the resolved value.

Nothing applies it to mcp.public. Reading that key alone reported an ability
as absent from MCP when the general flag had put it there, so the column was
telling the owner an ability was less exposed than it is -- the wrong
direction for a screen whose job is showing reach.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
WordPress 7.1 added meta.public as the general exposure flag, and the
precedence every channel resolves by: its own key, then that flag, then the
channel's default. The workspace was reading a bespoke key of its own, which
is the fragmentation issue #354 is about.

It reads ai-workspace.public ?? public ?? false now. An ability marked public
is eligible here without naming this surface, and naming it overrides in
either direction.

Two things follow, neither of them cosmetic.

The discovery query lost its meta condition. Core matches meta exactly and
cannot express the fallback, so querying the channel key would have silently
skipped every ability eligible only through the general flag. Resolution moved
per item, and a test holds the query empty so nobody optimises it back.

Two exclusion reasons became one. Core writes meta.public onto every ability
at registration and validates it as a boolean, so "the author declared
nothing" is not a state that exists any more -- an ability with no opinion is
eligible-false by core's default. Reporting it as undeclared would send an
author to add a key already there with the value they meant.

Inheriting widens who is eligible, so the effect class is what keeps that from
meaning every public ability can be called by a model that has been talked
into it. Five cases cover that: not readonly, destructive, open-world, the
hint absent, and no annotations at all.

has_declaration() is gone. Since core seeds the key, its fallback branch could
never return false, and nothing outside tests called it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Inheriting meta.public cost a lock nobody was counting. core/get-user-info
ships public, read-only and not destructive, so the only thing keeping a
reader of people's personal data off the assistant was an absent open_world
hint -- and open_world => false would be a correct, tidy thing for core to
add. One good-faith cleanup upstream and a PII tool lands on a surface
reachable by instructions embedded in content someone else wrote.

The effect class cannot catch that. It asks whether an ability writes or
reaches outside the site, not whether handing it to a model is a bad idea. So
a short list answers the second question for the four core abilities where it
is plainly yes: user info, users, settings, environment.

WordPress registers those, not this plugin, so a list here is the only place
the decision can live. It is filterable, because a site with code access
deciding otherwise is different from a default deciding for them, and it is
checked before the curated floor so it holds on every route in.

A test registers a fixture that is declared, annotated impeccably, and on the
list, then asserts it never reaches the model -- so the refusal is provably
the list rather than something the fixture got wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fifteen rows said "Not public, and has not opted in to the assistant" under a
badge already reading "not the assistant", which buried the three rows where
the reason is the whole point: held back for reading personal data, refused on
effect class, or waiting on the admission gate.

The commonest reason is left unsaid now. The rest still print, because those
are the ones an owner or an author can act on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#1002 renamed core/read-users to core/users-query and kept the old name as a
deprecated alias. Both are registered, and the withheld list named only the
old one -- so the ability it exists to hold back was reachable under the name
core now prefers.

Not reachable in practice yet: core/users-query is not public and does not
assert an admissible effect class. But the point of the list is to hold when
those change, and it was not holding.

Both names are listed, because the alias is a real registration rather than a
redirect. A rename upstream is a hole here until someone adds the new name,
which the docblock now says.

PHPStan goes with it. The composer script pinned 1G, and this branch pushed
analysis past it once develop merged in, so the documented command crashed.
2G clears it. CI never used that script -- it runs phpstan with no limit at
all -- so this is the local command catching up to what CI already did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@whyisjake
whyisjake force-pushed the feat/abilities-tool-surface branch from f2ac240 to 3467f15 Compare September 11, 2026 03:12
#1002 renamed core/read-content to core/content-query. Nine docblock and doc
references here still pointed at the old name, which now resolves to a
deprecated alias -- accurate enough to pass review and wrong enough to send
the next reader somewhere that will be removed.

The withheld list keeps naming both sides of the users rename, and the docs
now say why: the alias is a real registration that copies the replacement's
meta, so exposure changes reach both names at once and holding back only one
holds back nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@whyisjake
whyisjake marked this pull request as ready for review September 11, 2026 04:00
@whyisjake
whyisjake requested a review from a team September 11, 2026 04:00
@whyisjake
whyisjake requested a review from jeffpaul as a code owner September 11, 2026 04:00
@github-actions

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: whyisjake <whyisjake@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Let the AI Workspace's tool surface come from the Abilities API

1 participant