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:
@@ -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 —
|
||||
|
||||
@@ -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 `<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.
|
||||
|
||||
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 `<img>` 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
|
||||
```
|
||||
|
||||
`<publisher>/<plugin>`, 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`.
|
||||
+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