Files
platform/docs/affordances.md
T
pastilhas c68c734431 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.
2026-08-19 20:30:21 +00:00

8.7 KiB

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 ![](/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 <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.

---
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

<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.