docs: what an affordance is, and the test that catches the bad ones

the substance was in plugins/CLAUDE.md marked "DESIGN, NOT BUILT", which
stopped being true the same day it was written — load.ts reads them, publish.ts
copies a plugin's in on install, and claude-manager compiles them into every
session. moved to docs/affordances.md and corrected; plugins/CLAUDE.md keeps
the plugin-specific part and points here.

the new part is a third test, earned today by nearly failing it twice: CAN IT
BE FIXED INSTEAD? an affordance is for something irreducible about the
surface, and a defect is not irreducible.

both candidates drafted today failed it. "links open in the same tab" was one
missing line in MD_COMPONENTS. "AskUserQuestion never returns an answer" was
true when the code comment was written and already fixed by the time i read
it — that one would have shipped a permanent instruction to work around a bug
that no longer existed, in a file whose whole purpose is to be believed
without checking.

names the failure mode: 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 a bug.

also records two things that are not built and read as if they were: nothing
is scoped, so every affordance is in every conversation, and affordanceBlock()
has exactly one caller — an opencode turn gets none at all.
This commit is contained in:
2026-08-19 20:30:21 +00:00
parent 4cd371b55d
commit c68c734431
3 changed files with 183 additions and 104 deletions
+16 -104
View File
@@ -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/<publisher>/<appName>/`, 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 `<img>` carries no bearer token, so it can only load what the browser can fetch
unauthenticated. A fresh agent knows none of that. It writes `![](/home/me/chart.png)`, 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
---
`![alt](https://…)` 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
```
`<publisher>/<plugin>`, 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: