load affordances and append them to every claude session

The platform hosts the agent and never introduces itself. An agent gets a
shell, a filesystem and a prompt; everything about the CLIENT on the other end
has to be guessed. It writes ![](/home/me/chart.png), nothing renders, and
nothing says why — because an <img> carries no bearer token and it is the
user's DEVICE that fetches the URL.

servers/affordances/load.ts reads capabilities/affordances/ and compiles one
block; claude-manager passes it as systemPrompt.append with the claude_code
preset, so the harness keeps its own prompt.

Passed there rather than written to a file because it is the one place every
session goes through, owner and member alike. A generated CLAUDE.md cannot do
that — user-instance.ts deliberately refuses to write one for the owner, since
it would pollute the personal ~/.claude/CLAUDE.md they maintain themselves.

Read per session, not cached: capabilities/ is already read fresh each session,
so a plugin installed an hour ago is live on the next message. Synchronous
because createSession is not async, and making it so to read a few kilobytes
would ripple through every caller.

No filtering. Everything is loaded into every conversation, because the axis to
filter on is not one dimension — for image rendering it is what the CLIENT
renders, for gitea what TOOLING exists on the machine. Fine at two files, not
at twenty; TODO.md carries the argument.

Opencode gets nothing, and that is recorded as a gap rather than hidden: an
affordance that holds on one harness is the drift this removes. Deliberate —
opencode is paused pending v2 and the work would be thrown away.

Nine tests, aimed at the failure that matters: a file that silently does not
reach the agent looks exactly like one nobody wrote.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 07:27:01 +00:00
co-authored by Claude Opus 5
parent d8c97b6814
commit 057bf7b183
4 changed files with 225 additions and 10 deletions
+112
View File
@@ -0,0 +1,112 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { OFFICER_ITEMS_DIR } from '../data-path';
// What the platform tells an agent about the surface it is speaking through.
//
// ── The problem ──
//
// The platform hosts the agent and never introduces itself. An agent gets a shell, a filesystem and a
// prompt; everything about the CLIENT on the other end has to be guessed. It writes
// `![](/home/me/chart.png)`, nothing renders, and nothing tells it why — because an `<img>` carries no
// bearer token, and it is the USER'S DEVICE that fetches the URL.
//
// That knowledge used to live as prose in a hand-written CLAUDE.md on one machine. This reads it from
// `capabilities/affordances/` instead, where it is versioned, inspectable, and contributable by a plugin.
//
// See `plugins/CLAUDE.md` for what an affordance is and what belongs in one.
//
// ── Deliberately dumb ──
//
// No filtering. Every affordance is compiled into every conversation, because the axis to filter on is
// not one dimension — for image rendering it is what the CLIENT renders, for gitea what TOOLING exists on
// the machine. Picking the wrong axis now and writing files against it costs more than loading two extra
// paragraphs. `TODO.md` carries the argument.
const YAML = (Bun as unknown as { YAML: { parse(input: string): unknown } }).YAML;
export const AFFORDANCES_DIR = join(OFFICER_ITEMS_DIR, 'affordances');
export type Affordance = {
/** `<publisher>/<plugin>/<file>` — provenance, and unique. */
id: string;
name: string;
description: string | null;
/** An INTEGER, matching `capabilities/agents/`. Not the semver a plugin carries. */
version: number;
/** What is actually sent. */
body: string;
};
function parse(raw: string): { fm: Record<string, unknown>; body: string } {
const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
if (!match) return { fm: {}, body: raw.trim() };
try {
return { fm: (YAML.parse(match[1]!) as Record<string, unknown>) ?? {}, body: match[2]!.trim() };
} catch {
// A broken header is not a reason to lose the body. The agent needs the prose far more than the name,
// and a file that vanished over a stray colon would be very hard to notice.
return { fm: {}, body: match[2]!.trim() };
}
}
/**
* Every `.md` under `affordances/`, at any depth, sorted so the compiled block is stable.
*
* SYNCHRONOUS on purpose. `createSession` is not async, and making it so to read a few kilobytes would
* ripple through every caller for no benefit — this runs once per session, not per request.
*/
export function loadAffordances(root: string = AFFORDANCES_DIR): Affordance[] {
let entries: string[];
try {
// `recursive` because the tree is `<publisher>/<plugin>/<file>.md` and neither level is known here.
entries = (readdirSync(root, { recursive: true }) as string[]).filter((f) => f.endsWith('.md'));
} catch {
// No directory yet is the ordinary state of a fresh install, not an error.
return [];
}
const found = entries.sort().map((rel): Affordance | null => {
try {
const { fm, body } = parse(readFileSync(join(root, rel), 'utf8'));
if (!body) return null;
const versionRaw = fm.version;
return {
id: rel.replace(/\.md$/, ''),
// Falls back to the path rather than to "Untitled": a heading naming the file beats one that
// says nothing, and it makes a missing `name` visible without failing the load.
name: fm.name == null ? rel.replace(/\.md$/, '') : String(fm.name),
description: fm.description == null ? null : String(fm.description),
version: typeof versionRaw === 'number' ? versionRaw : Number(versionRaw) || 1,
body,
};
} catch {
return null;
}
});
return found.filter((a): a is Affordance => a !== null);
}
/**
* One block for the system prompt, or null when there is nothing to say.
*
* `description` is NOT included — 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.
*/
export function compileAffordances(affordances: Affordance[]): string | null {
if (!affordances.length) return null;
return [
'# This environment',
'',
'What the surface you are talking through will and will not do. Not instructions — facts about the',
'client on the other end, which you cannot discover from the filesystem.',
'',
...affordances.map((a) => `## ${a.name}\n\n${a.body}`),
].join('\n');
}
/** Read and compile in one step, for callers that only want the string. */
export function affordanceBlock(root: string = AFFORDANCES_DIR): string | null {
return compileAffordances(loadAffordances(root));
}