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
@@ -4,6 +4,7 @@ import { homedir } from 'node:os';
import { join } from 'node:path';
import { query, type Query, type SpawnOptions, type SpawnedProcess } from '@anthropic-ai/claude-agent-sdk';
import type { ChatEvent, PromptImage } from '../../api/chat/types';
import { affordanceBlock } from '../../affordances/load';
import type { ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult, LiveClaudeSession } from '../protocol';
import { setClaudeSession, clearClaudeSession, getClaudeSession } from './state';
import { countsAsOutput, diagnoseExit, stderrReason } from './exit-diagnosis';
@@ -520,6 +521,10 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
`as=${params.member?.osUser ?? 'owner'} model=${params.model ?? 'default'}`,
);
// Read here rather than at module load: a plugin installed an hour ago should be live on the next
// message, and this costs a few kilobytes once per session.
const affordances = affordanceBlock();
const q = query({
prompt: input.gen as AsyncIterable<SdkUserMessage>,
options: {
@@ -593,6 +598,27 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
spawnClaudeCodeProcess: watchChild(spawnClaudeAsOwner, session),
}),
settingSources: ['user', 'project', 'local'],
/**
* What the platform tells the agent about the surface it is speaking through.
*
* `preset` keeps Claude Code's own system prompt and APPENDS to it — replacing it would throw away
* everything the harness knows about its own tools.
*
* Passed here rather than written to a file because this 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 agent session, so a
* plugin installed an hour ago is live on the next message without a restart.
*
* OPENCODE DOES NOT GET THIS, and that is a real gap rather than an oversight — an affordance that
* holds on one harness is the drift this exists to remove. Deliberate as of 2026-08-19: opencode
* development is paused pending its v2 migration, so the work would be thrown away. See TODO.md.
*/
...(affordances
? { systemPrompt: { type: 'preset' as const, preset: 'claude_code' as const, append: affordances } }
: {}),
env: cleanEnv as Record<string, string>,
stderr: (d: string) => {
if (!d.trim()) return;