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:
+16
-104
@@ -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 ``, 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
|
||||
```
|
||||
|
||||
`<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:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user