diff --git a/CLAUDE.md b/CLAUDE.md
index f5f1f5f4..db754599 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -421,6 +421,9 @@ explaining why it was safe.
- `docs/navigation-audit.md` — **authoritative** on routing/navigation: the opaque-click anti-pattern,
a severity-ranked findings table, the channel-selection map and the four-phase plan
+- `docs/affordances.md` — what an affordance is, the three tests for whether something qualifies (including
+ the one that catches most bad ones: _can it be fixed instead?_), the file format, and how they reach the
+ agent. Read before writing one
- `docs/agent-coordination.md` — **the north star** for the workspace/panel work: agents on one
dashboard coordinating with each other instead of through the human, the handoff protocol, and what
is deliberately _not_ being built. Read it before ranking, deferring or starting any panel item —
diff --git a/docs/affordances.md b/docs/affordances.md
new file mode 100644
index 00000000..98247b1d
--- /dev/null
+++ b/docs/affordances.md
@@ -0,0 +1,164 @@
+# Affordances
+
+**Status: BUILT.** Agreed and built 2026-08-19. `src/servers/affordances/load.ts` reads them,
+`publish.ts` copies a plugin's in on install, and `claude-manager.ts` compiles them into every session.
+Two exist: `image-rendering` and gitea's. **Not built:** scoping (everything loads unconditionally) and
+delivery to opencode, which currently receives none.
+
+## What one is
+
+> **An affordance is something the surface on the other end of the conversation offers the agent —
+> declared by the code that implements it, and read by the agent at runtime.**
+>
+> Not what the agent can do. What the client will honour.
+
+## Why it needs to exist
+
+The platform hosts the agent and never introduces itself. An agent gets a shell, a filesystem and a
+prompt; everything about the surface it is speaking _through_ has to be smuggled in as prose somebody
+maintains by hand.
+
+The worked example is images. Officer's chat renders markdown images — `ChatImage` is wired into
+`react-markdown` — but an `
` carries no bearer token, so it can only load what the browser can fetch
+unauthenticated. A fresh agent knows none of that. It writes ``, nothing renders,
+and nothing tells it why.
+
+Three properties make prose the wrong container:
+
+- **It drifts.** The file describes a workaround; the code has a reason. Neither knows about the other.
+- **It is per-machine.** It lives in one repository, copied by hand.
+- **A plugin cannot contribute to it.** Install a plugin that adds a surface and no agent learns it
+ exists — which is the case that matters most, because a plugin is exactly a new surface arriving at
+ runtime.
+
+## The boundary
+
+Four neighbours, easily confused:
+
+| | answers |
+| -------------- | ---------------------------------------------------- |
+| **tool** | what the agent can _do_ — it acts, something happens |
+| **permission** | what this _account_ may reach |
+| **skill** | instructions the agent _follows_ |
+| **affordance** | what the _client_ will render, accept or ignore |
+
+Three tests settle almost everything.
+
+### 1. Would it change if the same agent talked to a different client?
+
+A phone app, a terminal, a webhook, a cron run with no human at all. If yes, it is an affordance.
+Rendering an image is; reading a file is not.
+
+### 2. Does the agent have to guess it today?
+
+If the honest answer is "it writes the markdown and hopes", that is a missing affordance.
+
+### 3. Can it be fixed instead? — then fix it
+
+**This is the one that is easy to get wrong, and the most expensive when you do.** An affordance is for
+something _irreducible_ about the surface. A defect is not irreducible. It is a defect.
+
+Both candidates written on 2026-08-19 failed this test:
+
+- **"Links open in the same tab, so warn the agent."** `a` was simply missing from `MD_COMPONENTS` in
+ `MessageBubble`. One line. Fixed.
+- **"`AskUserQuestion` never returns an answer, so don't wait for one."** True when the code comment was
+ written; already fixed by the time the affordance was drafted. It would have shipped a permanent
+ instruction to work around a bug that no longer existed.
+
+The failure mode is specific and worth naming: **an affordance describing a defect calcifies it.** Once
+there is a document explaining the workaround, the defect has a rationale, and the rationale outlives
+anyone's memory that it was ever a bug. The second example is worse than the first — it would have taught
+every future agent something false, in a file whose whole purpose is to be believed without checking.
+
+Image rendering earns its place precisely because it fails this test in the other direction: "the user's
+device fetches the URL" is how `
` works. No code change makes it untrue.
+
+## The rule that makes it worth having
+
+**An affordance is declared where it is implemented.** `ChatImage` declares that images render, because
+`ChatImage` is what makes them render. The moment it becomes a list maintained somewhere else, it is the
+hand-written `CLAUDE.md` problem again with a better name — and this time with a build step.
+
+Same posture as `permissions/registry.ts`, and for the same reason: one enumeration, owned by the code it
+describes, which cannot quietly disagree with reality.
+
+## The file
+
+Markdown with YAML frontmatter, like every other item in `capabilities/`. Frontmatter is for the
+PLATFORM; the body is for the agent.
+
+```markdown
+---
+name: Image rendering
+description: Use when you have an image to show the user in chat.
+version: 1
+---
+
+`` renders as an image. **The user's device fetches the URL**, so it must be
+reachable from there — a local path, `file://`, or anything behind authentication renders nothing,
+and fails silently.
+```
+
+| field | |
+| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `name` | becomes the heading in the compiled block, so the agent can index it |
+| `description` | when to reach for it. Costs the agent nothing — it is **not** sent |
+| `version` | an INTEGER, not semver, matching `capabilities/agents/`. A plugin-contributed affordance sits near two version numbers meaning different things: this integer is the revision of _this text_, the plugin's semver is the plugin's. Do not reconcile them |
+
+A broken frontmatter header does not lose the body — `load.ts` falls back to the path for `name` rather
+than dropping the file. The agent needs the prose far more than the label, and a file that vanished over
+a stray colon would be very hard to notice.
+
+### Keep the body short
+
+Every affordance is loaded into every conversation, so it is charged to the context budget of every turn.
+State the constraint, name the silent failures, say what to do instead. Nothing else.
+
+Write it as **facts about the client**, not instructions to the agent. "The user's device fetches the URL"
+survives a redesign of the agent's behaviour; "always use a public URL" does not, and is one step removed
+from the reason.
+
+## Where they live
+
+```
+capabilities/affordances/
+ officer/core/ ← core, shipped by the setup script from the capabilities repository
+ image-rendering.md
+ officerdev/gitea/ ← contributed by the gitea plugin on install
+ gitea.md
+```
+
+`/`, taken from the manifest verbatim. `officer/core` is not a plugin: it is the
+platform's own. The two spellings mark two different things and should not be normalised into one.
+
+A plugin ships an `affordances/` directory in its repository; installing copies it in, uninstalling
+removes it — the same shape as `assets/` and `publishAssets`, deliberately, because two mechanisms for
+"the plugin ships some files" would drift.
+
+**The namespace is derived from where the file lands**, never from anything inside it — the same rule as
+`mountPrefix` and the notification catalogue.
+
+## How they reach the agent
+
+`claude-manager.ts` calls `affordanceBlock()` once per session and prepends the compiled block to the
+system prompt. Read at spawn rather than at module load, so a plugin installed an hour ago is live on the
+next message.
+
+`description` is deliberately **not** included in the compiled block: it answers "when should I reach for
+this", which only matters when an agent chooses what to load. Everything here is already loaded, so it
+would be paid for and unread.
+
+### Deliberately dumb, for now
+
+No filtering. Every affordance is compiled into every conversation. A `surface` field was drafted and
+dropped because it wore one name for two different axes: for image rendering it means _what the client
+renders_, for gitea _what tooling exists on the machine_. Picking the wrong axis and writing files against
+it costs more than loading two extra paragraphs.
+
+Fine at two files. Not fine at twenty. See `TODO.md`.
+
+### opencode gets none
+
+`affordanceBlock()` has exactly one caller and it is the Claude sidecar. An opencode turn runs with no
+affordances at all, so an agent driven through it still guesses at images. Tracked in `TODO.md`.
diff --git a/plugins/CLAUDE.md b/plugins/CLAUDE.md
index 3d65139a..2eb6a49d 100644
--- a/plugins/CLAUDE.md
+++ b/plugins/CLAUDE.md
@@ -11,115 +11,27 @@ appears by `git clone` when somebody installs one, and is gitignored here.
## Affordances
-**DESIGN, NOT BUILT.** Agreed 2026-08-19. There is no registry, no API and nothing to call yet. It is
-written down now because the name and the boundary are the parts that are expensive to change later, and
-because a plugin author who invents a different word for this will be hard to merge with afterwards.
+**`docs/affordances.md` is the authority** — what one is, the three tests for whether something qualifies,
+the file format and how they reach the agent. Read it before writing one. What follows is only the part
+that is specific to shipping one from a plugin.
-### What one is
+> An affordance is something the surface on the other end of the conversation offers the agent — declared
+> by the code that implements it, and read by the agent at runtime. Not what the agent can do; what the
+> client will honour.
-> **An affordance is something the surface on the other end of the conversation offers the agent —
-> declared by the code that implements it, and read by the agent at runtime.**
->
-> Not what the agent can do. What the client will honour.
+A plugin ships an `affordances/` directory of markdown-with-frontmatter in its repository. Installing
+copies it to `capabilities/affordances///`, uninstalling removes it — the same shape
+as `assets/` and `publishAssets`, and the namespace comes from where it lands rather than from anything
+inside the file.
-### Why it needs to exist
+**The test that catches most bad ones: can it be fixed instead?** If the answer is yes, fix it. An
+affordance describing a defect gives that defect a rationale, and the rationale outlives everyone's memory
+that it was a bug. See the two worked examples in `docs/affordances.md`.
-The platform hosts the agent and never introduces itself. An agent gets a shell, a filesystem and a
-prompt; everything about the surface it is speaking _through_ has to be smuggled in as prose somebody
-maintains by hand.
+Keep it short — every affordance is loaded into every conversation on this machine, including turns that
+have nothing to do with your plugin.
-The worked example is images. Officer's chat renders markdown images — `ChatImage` is wired into
-`react-markdown` — but an `
` carries no bearer token, so it can only load what the browser can fetch
-unauthenticated. A fresh agent knows none of that. It writes ``, nothing renders,
-and nothing tells it why. The knowledge currently lives in a hand-written `CLAUDE.md` on one machine,
-which says "use a public URL" — a workaround, one step removed from the actual constraint, with nothing
-keeping the two in sync.
-
-Three properties make prose the wrong container for this:
-
-- **It drifts.** The file describes a workaround; the code has a reason. Neither knows about the other.
-- **It is per-machine.** It lives in one repository, copied by hand.
-- **A plugin cannot contribute to it.** Install a plugin that adds a surface and no agent learns it
- exists — which is the case that matters here, because a plugin is exactly a new surface arriving at
- runtime.
-
-### The boundary
-
-Three neighbours, easily confused:
-
-| | answers |
-| -------------- | ---------------------------------------------------- |
-| **tool** | what the agent can _do_ — it acts, something happens |
-| **permission** | what this _account_ may reach |
-| **skill** | instructions the agent _follows_ |
-| **affordance** | what the _client_ will render, accept or ignore |
-
-Two tests settle most cases:
-
-1. **Would it change if the same agent talked to a different client?** A phone app, a terminal, a
- webhook, a cron run with no human at all. If yes, it is an affordance. Rendering an image is; reading
- a file is not.
-2. **Does the agent have to guess it today?** If the honest answer is "it writes the markdown and hopes",
- that is a missing affordance.
-
-### The rule that makes it worth having
-
-**An affordance is declared where it is implemented.** `ChatImage` declares that images render, because
-`ChatImage` is what makes them render. The moment it becomes a list maintained somewhere else, it is the
-hand-written `CLAUDE.md` problem again with a better name — and this time with a build step.
-
-Same posture as `permissions/registry.ts`, and for the same reason: one enumeration, owned by the code it
-describes, which cannot quietly disagree with reality.
-
-### The file
-
-Markdown with YAML frontmatter, like every other item in `capabilities/`. Frontmatter is for the
-PLATFORM; the body is for the agent.
-
-```markdown
----
-name: Image rendering
-description: Use when you have an image to show the user in chat.
-version: 1
----
-
-`` renders as an image. **The user's device fetches the URL**, so it must be
-reachable from there — a local path, `file://`, or anything behind authentication renders nothing,
-and fails silently.
-```
-
-| field | |
-| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `name` | becomes the heading in the compiled block, so the agent can index it |
-| `description` | when to reach for it. Costs the agent nothing — it is not sent |
-| `version` | an INTEGER, not semver, matching `capabilities/agents/` (`agent-files.ts` parses it that way). A plugin-contributed affordance sits near two version numbers and they mean different things: this integer is the revision of _this text_, the plugin's semver is the plugin's. Do not reconcile them |
-
-**Keep the body short.** Every affordance is loaded into every conversation, so it is charged to the
-context budget of every turn. State the constraint, name the silent failures, say what to do instead.
-
-There is deliberately no `surface` field yet. It was drafted and dropped: for the image affordance it
-would mean _what the client renders_, and for the gitea one below _what tooling exists on the machine_ —
-two different axes wearing one name. Everything is loaded unconditionally until that is worked out,
-which is fine at two files and will not be at twenty. See `TODO.md`.
-
-### Where they live
-
-```
-capabilities/affordances/
- officer/core/ ← core, installed by the setup script
- image-rendering.md
- officer/gitea/ ← contributed by the gitea plugin on install
- gitea.md
-```
-
-`/`, taken from the manifest verbatim — so a plugin published by `officerdev` lands
-under `officerdev/`. `officer/core` is not a plugin: it is the platform's own, shipped by the setup
-script from the capabilities repository. The two spellings mark two different things and should not be
-normalised into one. A plugin ships an
-`affordances/` directory in its repository; installing copies it in, uninstalling removes it — the same
-shape as `assets/` and `publishAssets`.
-
-## Notifications — the same shape, for push
+### A plugin-contributed one, for reference## Notifications — the same shape, for push
A plugin can also declare what its push notifications MEAN. Same mechanism, different store: