# 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 ``, 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
---
`` 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`.