Files
platform/docs/plugin-capabilities.md
T
pastilhasandClaude Opus 5 6572fa90f0 design: plugin capabilities are loaded in place, not copied
capabilities/ is meant to be forked — core's work plus the owner's, and nothing
else. Four publishers copy plugin files into it at install, so a fork
accumulates files it did not author with nothing on disk saying which. On this
machine skills/email-db/ and extensions/ are sitting untracked in that
repository right now, and there is no good answer: commit them and the fork
carries another plugin's work, leave them and git status is permanently noisy.

Copying is wrong a second way that has nothing to do with git. A copy goes
stale — plugins:update pulls new plugin code while the copied capability keeps
describing the old one — and a copy survives plugins:disable, so a disabled
plugin's skill stays in the agent's index.

So the platform reads them where they are: the capabilities directory, plus each
installed and enabled plugin's own. Nothing is copied, and nothing needs a
namespace to stay apart because separate directories already do that
structurally rather than by convention.

Four bugs stop existing: forks stay clean, capabilities update with the plugin,
disable actually disables, and uninstall needs no cleanup — so there is no list
of filenames to get wrong and delete the owner's work with.

Deletes all four publishers when built, including api/tasks/publish.ts from
earlier today, superseded before it shipped anywhere. app-store/assets stays —
those are images served over HTTP, and the copy is what makes them reachable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-23 23:11:19 +00:00

5.3 KiB

A plugin's capabilities are LOADED, never copied

Status: designed, not built. What exists today is the copying this replaces.

The problem, as it is on disk right now

capabilities/ is a git repository. The intent is that anyone can fork it, keep their own tasks, skills and affordances in it, and pull core's on top. That only works if the directory contains exactly two things: what core ships, and what the owner wrote.

It does not. Four publishers copy a plugin's files into it at install time — assets, affordances, notification catalogues and skills — so a fork accumulates files it did not author. On this machine today skills/email-db/ and extensions/ sit untracked in that repository, published there by plugins. Commit them and the fork carries another plugin's work; leave them and git status is permanently noisy. There is no third answer, because nothing on disk says which files came from where.

Copying is also wrong in a second way that has nothing to do with git. A copy goes stale. plugins:update pulls new plugin code and the copied capabilities keep describing the old version until something republishes them. And a copy survives plugins:disable, so a disabled plugin's skill stays in the agent's index — the plugin is off and the agent still believes it is there.

The design

A plugin keeps its capabilities in its own directory, and the platform reads them where they are.

capabilities/                      the owner's fork: core's, plus their own. Nothing else, ever.
  tasks/  skills/  affordances/  notifications/

plugins/<name>/                    the plugin's own repository, cloned on install
  tasks/  skills/  affordances/  notifications/

Nothing is copied. Nothing needs a namespace to keep the two apart, because they are already apart — which is the whole reason this beats scoping the paths. A namespace makes provenance readable; separate directories make it structural.

Four consequences, and each of them is a bug that stops existing:

  • A fork stays clean. capabilities/ contains only what its owner is responsible for, so pulling from upstream merges without touching anything a plugin put there.
  • Capabilities update with the plugin. plugins:update is a git pull in the plugin's directory, and the capabilities are in that directory. There is nothing left to go stale.
  • Disable actually disables. The load list is built from plugins that are installed AND enabled, so a disabled plugin contributes nothing. Today its copies remain.
  • Uninstall needs no cleanup step. The directory goes and its capabilities go with it. No list of filenames to be careful with, and no way to delete the owner's own work by getting that list wrong.

What has to change

The loaders take a list of roots. loadSkills(root) and loadAffordances(root) already take one with a default, so this is a small change rather than a rewrite. Notifications the same.

The list is resolved per session, not at module load. affordanceBlock() and skillsBlock() are called once per session in sidecar/claude/claude-manager.ts — deliberately, so a capability written an hour ago is offered on the next session. That is also why they can afford to ask the database which plugins are installed: once per session, not once per turn.

Tasks are the awkward one, because that store is read-write. api/tasks/task-files.ts creates, edits and deletes as well as reads. Reading must span every source; writing must only ever touch the owner's directory. A plugin's task is read-only by nature and the UI has to know, or it will offer an Edit that silently writes into a directory the next plugins:update overwrites.

A task's directory name is also stored data — taskDirName in pipeline_jobs — so lookup by name has to search every source rather than assuming one root.

The four publishers are deleted, along with their install and uninstall wiring: affordances/publish.ts, notifications/publish.ts, skills/publish.ts, api/tasks/publish.ts. The last was written on 2026-08-23 and is superseded before it ever shipped.

app-store/assets is a different case and stays. Those are images served over HTTP from a platform-managed path, not capabilities read from disk, and the copy is what makes them reachable.

The migration

One-off, and it is deletion rather than movement. The copies already in capabilities/ have nothing to remove them once the publishers are gone — skills/email-db/ and extensions/ on this machine, and whatever the equivalent is per host. They are copies of files that still exist in each plugin's own directory, so deleting them loses nothing.

Check each host rather than assuming this one is representative.

What this does not decide

Where the owner's own capabilities live inside their fork. Flat at the top of each type directory is what happens today and needs no change. Scoping them is a separate question and this design does not force it either way.

Whether a plugin's capabilities should be namespaced among themselves. Two plugins can still ship a task with the same directory name, and the loader has to break that tie — first-come, as the items store has always done, or by qualifying with the plugin name at read time. Worth settling when a second plugin ships a task; not before.