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,
|
- `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
|
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
|
- `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
|
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 —
|
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
|
## Affordances
|
||||||
|
|
||||||
**DESIGN, NOT BUILT.** Agreed 2026-08-19. There is no registry, no API and nothing to call yet. It is
|
**`docs/affordances.md` is the authority** — what one is, the three tests for whether something qualifies,
|
||||||
written down now because the name and the boundary are the parts that are expensive to change later, and
|
the file format and how they reach the agent. Read it before writing one. What follows is only the part
|
||||||
because a plugin author who invents a different word for this will be hard to merge with afterwards.
|
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 —
|
A plugin ships an `affordances/` directory of markdown-with-frontmatter in its repository. Installing
|
||||||
> declared by the code that implements it, and read by the agent at runtime.**
|
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
|
||||||
> Not what the agent can do. What the client will honour.
|
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
|
Keep it short — every affordance is loaded into every conversation on this machine, including turns that
|
||||||
prompt; everything about the surface it is speaking _through_ has to be smuggled in as prose somebody
|
have nothing to do with your plugin.
|
||||||
maintains by hand.
|
|
||||||
|
|
||||||
The worked example is images. Officer's chat renders markdown images — `ChatImage` is wired into
|
### A plugin-contributed one, for reference## Notifications — the same shape, for push
|
||||||
`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 can also declare what its push notifications MEAN. Same mechanism, different store:
|
A plugin can also declare what its push notifications MEAN. Same mechanism, different store:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user