From c68c7344318b5e3fc69f0bead393cb75c27c6799 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Wed, 19 Aug 2026 20:30:21 +0000 Subject: [PATCH] docs: what an affordance is, and the test that catches the bad ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CLAUDE.md | 3 + docs/affordances.md | 164 ++++++++++++++++++++++++++++++++++++++++++++ plugins/CLAUDE.md | 120 +++++--------------------------- 3 files changed, 183 insertions(+), 104 deletions(-) create mode 100644 docs/affordances.md 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 `![](/home/me/chart.png)`, 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 +--- + +`![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/`. 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 `![](/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 -``` - -`/`, 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: