Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions docs/adapters/claude-local.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,19 @@ title: Claude Local
summary: Claude Code local adapter setup and configuration
---

The `claude_local` adapter runs Anthropic's Claude Code CLI locally. It supports session persistence, skills injection, and structured output parsing.
The `claude_local` adapter runs Anthropic's Claude Code CLI locally. It supports session persistence, stable prompt-bundle materialization, skills injection, and structured output parsing.

## Prerequisites

- Claude Code CLI installed (`claude` command available)
- `ANTHROPIC_API_KEY` set in the environment or agent config
- Claude subscription login (`claude login`) or `ANTHROPIC_API_KEY` set in the environment or agent config

## Configuration Fields

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `cwd` | string | Yes | Working directory for the agent process (absolute path; created automatically if missing when permissions allow) |
| `model` | string | No | Claude model to use (e.g. `claude-opus-4-6`) |
| `model` | string | No | Claude model to use (e.g. `claude-opus-4-7` or `claude-sonnet-4-6`) |
| `promptTemplate` | string | No | Prompt used for all runs |
| `env` | object | No | Environment variables (supports secret refs) |
| `timeoutSec` | number | No | Process timeout (0 = no timeout) |
Expand Down Expand Up @@ -45,7 +45,9 @@ If resume fails with an unknown session error, the adapter automatically retries

## Skills Injection

The adapter creates a temporary directory with symlinks to Paperclip skills and passes it via `--add-dir`. This makes skills discoverable without polluting the agent's working directory.
The adapter creates a company-scoped, content-addressed prompt bundle under the active Paperclip instance and passes it via `--add-dir`. The bundle includes symlinks to selected Paperclip skills and, when configured, a stable `agent-instructions.md` used with `--append-system-prompt-file`. This keeps repeated agent instructions and skill manifests byte-stable across heartbeats, which lets Claude's prompt caching recognize repeated context.

Prompt-cache hit telemetry is captured from Claude's `usage.cache_read_input_tokens` stream field. Paperclip normalizes that value to `usage.cachedInputTokens`, stores it on the run usage JSON, and writes it into the cost ledger's cached input token column.

For manual local CLI usage outside heartbeat runs (for example running as `claudecoder` directly), use:

Expand Down
1 change: 1 addition & 0 deletions packages/adapter-utils/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ export interface AdapterInvocationMeta {
commandArgs?: string[];
commandNotes?: string[];
env?: Record<string, string>;
promptCache?: Record<string, unknown>;
prompt?: string;
promptMetrics?: Record<string, number>;
context?: Record<string, unknown>;
Expand Down
1 change: 1 addition & 0 deletions packages/adapters/claude-local/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,5 @@ Operational fields:

Notes:
- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling.
- Repeated agent instructions and Paperclip skills are materialized into a company-scoped, content-addressed prompt bundle under the Paperclip instance. Claude usage telemetry exposes prompt-cache hits as usage.cachedInputTokens and cost ledger cached input tokens.
`;
8 changes: 8 additions & 0 deletions packages/adapters/claude-local/src/server/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -749,6 +749,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
commandArgs: args,
commandNotes,
env: loggedEnv,
promptCache: {
strategy: "content-addressed-prompt-bundle",
bundleKey: promptBundle.bundleKey,
rootDir: promptBundle.rootDir,
addDir: effectivePromptBundleAddDir,
instructionsFilePath: effectiveInstructionsFilePath ?? null,
telemetry: "usage.cachedInputTokens",
},
prompt,
promptMetrics,
context,
Expand Down
21 changes: 18 additions & 3 deletions server/src/__tests__/claude-local-execute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ process.exit(${exit});
await fs.chmod(commandPath, 0o755);
}

async function writeFakeClaudeCommand(commandPath: string): Promise<void> {
async function writeFakeClaudeCommand(commandPath: string, opts: { cachedInputTokens?: number } = {}): Promise<void> {
const cachedInputTokens = opts.cachedInputTokens ?? 0;
const script = `#!/usr/bin/env node
const fs = require("node:fs");
const path = require("node:path");
Expand Down Expand Up @@ -68,7 +69,7 @@ if (capturePath) {
}
console.log(JSON.stringify({ type: "system", subtype: "init", session_id: "claude-session-1", model: "claude-sonnet" }));
console.log(JSON.stringify({ type: "assistant", session_id: "claude-session-1", message: { content: [{ type: "text", text: "hello" }] } }));
console.log(JSON.stringify({ type: "result", session_id: "claude-session-1", result: "hello", usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 } }));
console.log(JSON.stringify({ type: "result", session_id: "claude-session-1", result: "hello", usage: { input_tokens: 1, cache_read_input_tokens: ${cachedInputTokens}, output_tokens: 1 } }));
`;
await fs.writeFile(commandPath, script, "utf8");
await fs.chmod(commandPath, 0o755);
Expand Down Expand Up @@ -688,7 +689,7 @@ describe("claude execute", () => {
const paperclipHome = path.join(root, "paperclip-home");
await fs.mkdir(workspace, { recursive: true });
await fs.writeFile(instructionsPath, "You are managed instructions.\n", "utf8");
await writeFakeClaudeCommand(commandPath);
await writeFakeClaudeCommand(commandPath, { cachedInputTokens: 7 });

const previousHome = process.env.HOME;
const previousPaperclipHome = process.env.PAPERCLIP_HOME;
Expand All @@ -697,6 +698,7 @@ describe("claude execute", () => {
process.env.PAPERCLIP_HOME = paperclipHome;
delete process.env.PAPERCLIP_INSTANCE_ID;

const metaEvents: Array<Record<string, unknown>> = [];
try {
const first = await execute({
runId: "run-1",
Expand Down Expand Up @@ -725,6 +727,9 @@ describe("claude execute", () => {
context: {},
authToken: "run-jwt-token",
onLog: async () => {},
onMeta: async (meta) => {
metaEvents.push(meta as unknown as Record<string, unknown>);
},
});

expect(first.exitCode).toBe(0);
Expand Down Expand Up @@ -796,10 +801,15 @@ describe("claude execute", () => {
},
authToken: "run-jwt-token",
onLog: async () => {},
onMeta: async (meta) => {
metaEvents.push(meta as unknown as Record<string, unknown>);
},
});

expect(second.exitCode).toBe(0);
expect(second.errorMessage).toBeNull();
expect(first.usage?.cachedInputTokens).toBe(7);
expect(second.usage?.cachedInputTokens).toBe(7);

const capture1 = JSON.parse(await fs.readFile(capturePath1, "utf8")) as CapturePayload;
const capture2 = JSON.parse(await fs.readFile(capturePath2, "utf8")) as CapturePayload;
Expand All @@ -825,6 +835,11 @@ describe("claude execute", () => {
expect(capture2.argv).toContain("claude-session-1");
expect(capture2.prompt).toContain("## Paperclip Resume Delta");
expect(capture2.prompt).not.toContain("Follow the paperclip heartbeat.");

const latestPromptCache = metaEvents.at(-1)?.promptCache as Record<string, unknown> | undefined;
expect(latestPromptCache?.strategy).toBe("content-addressed-prompt-bundle");
expect(latestPromptCache?.telemetry).toBe("usage.cachedInputTokens");
expect(latestPromptCache?.addDir).toBe(capture2.addDir);
} finally {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
Expand Down
Loading