@blackbelt-technology/pi-anthropic-messages

Anthropic-messages protocol bridge for pi. Activates for any anthropic-messages API session (Anthropic OAuth/API-key + proxy providers): canonicalizes lowercase pi core tools (read/write/bash/grep → Read/Write/Bash/Grep), namespaces custom tools under mcp

Packages

Package details

extension

Install @blackbelt-technology/pi-anthropic-messages from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:@blackbelt-technology/pi-anthropic-messages
Package
@blackbelt-technology/pi-anthropic-messages
Version
0.3.2
Published
May 16, 2026
Downloads
not available
Author
mbotond
License
MIT
Types
extension
Size
49.6 KB
Dependencies
0 dependencies · 1 peer
Pi manifest JSON
{
  "extensions": [
    "./extensions/index.ts"
  ]
}

Security note

Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.

README

@blackbelt-technology/pi-anthropic-messages

Protocol-level bridge for pi when talking to Claude-model anthropic-messages endpoints — direct Anthropic (OAuth or API key), 9Router cc/claude-*, pi-model-proxy with a Claude backend, or any other proxy that forwards Claude Code-flavored traffic through the anthropic-messages Messages API.

What it does

Claude Code's upstream endpoints accept tools in exactly three flavours:

  1. Core Claude Code tools by canonical name — Read, Write, Edit, Bash, Grep, Glob, AskUserQuestion, Agent, WebFetch, WebSearch, …
  2. MCP tools, i.e. anything whose name starts with mcp__<server>__.
  3. Anthropic-native typed tools (computer_use, text_editor_20241022…).

pi-coding-agent registers its built-in tools under lowercase names (read, bash, edit, write, grep) and extensions register custom pi tools under names like ask_user, web_search, fetch_content, subagent. None of those match the Claude Code canonical allowlist, so without intervention they either get stripped or the endpoint mangles them with an _ide suffix. The model then falls back to a hallucinated bash_ide and every tool call fails.

This extension intervenes at the protocol layer:

  • Outbound (before_provider_request): rewrites pi tool names to something the endpoint accepts. Canonical pi core tools (read, write, bash, grep) become their Claude Code capitalization (Read, Write, Bash, Grep). Everything else that isn't already canonical, already mcp__*-prefixed, or aliased to an Anthropic-native tool becomes mcp__pi__<name>. tool_choice.name and historical tool_use blocks in message history get the same rewrites. The system prompt is lightly rewritten (pithe cli) so Claude Code identity fingerprints don't trip on the word "pi".
  • Inbound (message_end): translates the model's renamed tool calls back to their original pi names before the agent dispatches, so existing tool registrations work without modification. A defensive _ide suffix strip handles cases where the Claude Code endpoint mangles the response. Tools registered directly under a canonical Claude Code name (e.g. Agent, AskUserQuestion) are covered end-to-end: the reverse map contains an identity entry so Agent_ide strips back to the registered Agent handler.

The tool registry itself is never mutated — every other pi extension continues to register its tools under their original names and the bridge handles the translation transparently.

Activation — single tight gate

The bridge activates only when BOTH of these hold for the active session's model:

  1. ctx.model.api === "anthropic-messages"
  2. /claude/i.test(ctx.model.id ?? "") — the model id contains the case-insensitive substring claude
// Simplified:
function isClaudeAnthropicMessages(ctx) {
  return ctx.model?.api === "anthropic-messages"
      && /claude/i.test(ctx.model?.id ?? "");
}

When the gate fails — i.e. the session targets a non-Claude model on anthropic-messages (e.g. 9Router/glm/glm-5, 9Router/gemini/gemini-3-pro-preview) or any api other than anthropic-messagesevery hook handler is a true no-op: no tool rename, no mcp__pi__ prefix, no system prompt rewrite, no reverse map, no _ide strip. Tool names flow through pi's registry unchanged.

This is deliberate: the mcp__pi__ namespace and the canonical casing are Claude Code conventions. Non-Claude endpoints that happen to speak anthropic-messages format don't care about them, and the bridge shouldn't impose them.

Escape hatches

Two environment variables override the gate for the rare cases it doesn't match intent:

Variable Effect
PI_ANTHROPIC_MESSAGES_FORCE_CANONICAL=1 Forces the gate open for any anthropic-messages session regardless of model id. Useful for Claude models with unusual ids (e.g. c4-omega).
PI_ANTHROPIC_MESSAGES_DISABLE_CANONICAL=1 Forces the gate closed even for Claude-matching sessions. Useful for false positives (e.g. a non-Claude model whose id happens to contain "claude").

Both still require api === "anthropic-messages". For any other api the bridge is always a no-op.

For package authors

The short version

Register your tool with its natural name:

pi.registerTool({ name: "my_tool", /* … */ });

For Claude-model anthropic-messages sessions, the bridge automatically sends my_tool to the wire as mcp__pi__my_tool and translates the model's response back to my_tool before pi dispatches. For every other provider (OpenAI, Google, Bedrock, non-Claude anthropic-messages), the name is sent as-is. No configuration required.

When to register under a canonical name

If your tool has the exact name and a compatible schema with a canonical Claude Code tool (see Claude Code tools reference), register under the canonical name with its exact capitalization:

pi.registerTool({
  name: "Agent",          // matches canonical CC Agent tool
  parameters: /* … */     // schema compatible with CC's Agent
});

That passes through unchanged, giving Anthropic's surfaces a native rendering (e.g. the "Agent" card in Claude apps). The bridge's CC_CANONICAL_NAMES set lists every accepted canonical name.

Do not do this if your schema differs from the canonical one — the model will hallucinate canonical-shaped arguments and your handler will fail. When in doubt, use the natural lowercase/snake_case name and let the mcp__pi__ prefix take care of it.

Tools with no pi equivalent

Claude Code's WebSearch, WebFetch, AskUserQuestion, TodoWrite, NotebookEdit, ExitPlanMode, EnterPlanMode, KillShell, Skill, etc. have no direct pi equivalent. You don't need to do anything about them. If no extension registers a tool under those names, the model simply won't have them in this session — pi provides functional equivalents under mcp__pi__* (mcp__pi__ask_user, mcp__pi__web_search, mcp__pi__fetch_content, …) and the model uses those instead.

Schema adapters (planned)

The problem: name aliasing is not enough

Renaming web_searchWebSearch on the wire gives the model a canonical name it recognizes from training — but the model then calls the tool with Claude Code's expected input shape, not pi's. The schemas don't match:

Tool Claude Code canonical schema Pi extension schema Gap
web_searchWebSearch { query, allowed_domains?, blocked_domains? } { query?, queries?[], numResults?, recencyFilter?, domainFilter?[], provider?, workflow?, includeContent? } CC is a strict subset; pi supports multi-query, recency filtering, provider selection, content prefetch
fetch_contentWebFetch { url, prompt? } { url?, urls?[], prompt?, timestamp?, frames?, forceClone?, model? } CC is a strict subset; pi supports multi-URL, video frame extraction, GitHub clone
get_subagent_resultTaskOutput { task_id, wait?, verbose? } { agent_id, wait?, verbose? } Near-identical; field rename task_idagent_id
ask_userAskUserQuestion { question, options?[], allow_multiple? } { method: confirm|select|multiselect|input|batch, prompt, options?[], questions?[] } Fundamentally different model — pi's discriminated union with batch support loses too much expressiveness to adapt

Without schema translation, a name-only alias causes the model to send canonical-shaped input ({ query: "foo" }) to a handler expecting pi's shape — it silently works but the model can never discover or use pi's richer features (multi-query, recency, domain filtering, etc.).

Solution: per-tool binding adapters

Each tool that benefits from canonicalization gets a ToolBinding with an adapter that translates between the canonical wire schema and pi's handler schema:

export interface ToolBinding {
  /** Pi's registered tool name. */
  piName: string;

  /** Canonical name visible on the wire / to the model. */
  canonicalName: string;

  /**
   * Schema strategy:
   *   passthrough — pi's schema under the canonical name, zero translation.
   *   canonical   — canonical schema only; adaptInput required.
   *   hybrid      — canonical fields + pi-specific extras exposed.
   */
  schemaStrategy: "passthrough" | "canonical" | "hybrid";

  /** Schema sent to the model. Omitted for passthrough. */
  canonicalSchema?: TSchema;

  /** Reshape model's canonical input → pi handler's expected input. */
  adaptInput?: (wireInput: unknown) => unknown;

  /** Reshape pi's result → canonical result (rarely needed). */
  adaptOutput?: (piResult: unknown) => unknown;
}

The hybrid strategy is the sweet spot for feature-rich pi tools: it exposes the canonical fields the model is trained on plus pi-specific extras, so the model can use either shape. The adapter normalizes whichever the model produces.

Planned bindings

Pi tool Canonical Strategy Rationale
web_search WebSearch hybrid Expose query + CC domain fields + pi extras (queries[], recencyFilter, numResults). Adapter merges allowed_domains/blocked_domains → pi's domainFilter[] (prefix - for blocked).
fetch_content WebFetch hybrid Expose url + prompt + pi extras (urls[], timestamp, frames). Adapter normalizes url/urls to pi's shape.
get_subagent_result TaskOutput canonical Near-1:1 schema. Adapter renames task_idagent_id.
ask_user skip Pi's discriminated union (confirm/select/multiselect/input/batch) is fundamentally richer than CC's flat AskUserQuestion. Adapting would lose batch support, typed confirm UX, and multiselect semantics. Stays as mcp__pi__ask_user.

Data flow with adapters

  OUTBOUND (before_provider_request)
  ──────────────────────────────────
  1. Find binding by pi tool name
  2. Replace tool.name with binding.canonicalName
  3. If hybrid/canonical: replace tool.input_schema with binding.canonicalSchema
  4. Rewrite historical tool_use blocks in messages (name + input if needed)

  INBOUND (message_end)
  ─────────────────────
  1. Find binding by canonical name on tool_use block
  2. Restore tool_use.name to binding.piName
  3. If adaptInput defined: tool_use.input = binding.adaptInput(tool_use.input)
  4. Pi dispatches to original handler with pi-shaped arguments ✓

Adapter example: web_search → WebSearch

// Hybrid schema: CC canonical fields + pi extras.
// Model can use either { query, blocked_domains } (CC-trained)
// or { queries, recencyFilter, numResults } (pi-specific).
const webSearchBinding: ToolBinding = {
  piName: "web_search",
  canonicalName: "WebSearch",
  schemaStrategy: "hybrid",
  canonicalSchema: Type.Object({
    query:             Type.Optional(Type.String()),
    allowed_domains:   Type.Optional(Type.Array(Type.String())),
    blocked_domains:   Type.Optional(Type.Array(Type.String())),
    // pi extras:
    queries:           Type.Optional(Type.Array(Type.String())),
    numResults:        Type.Optional(Type.Number()),
    recencyFilter:     Type.Optional(StringEnum(["day","week","month","year"])),
  }),
  adaptInput: (cc: any) => {
    const out: Record<string, unknown> = {};
    // Prefer queries[] over query (pi's richer multi-query)
    if (cc.queries?.length) out.queries = cc.queries;
    else if (cc.query)      out.query = cc.query;
    // Merge CC domain fields → pi's unified domainFilter[]
    const dom: string[] = [];
    if (cc.allowed_domains) dom.push(...cc.allowed_domains);
    if (cc.blocked_domains) dom.push(...cc.blocked_domains.map((d: string) => `-${d}`));
    if (dom.length)         out.domainFilter = dom;
    if (cc.numResults    !== undefined) out.numResults    = cc.numResults;
    if (cc.recencyFilter !== undefined) out.recencyFilter = cc.recencyFilter;
    return out;
  },
};

Dynamic binding registration

Bindings can be registered in two ways:

  1. Built-in — shipped in extensions/bindings/ within this package for the recommended extensions (web_search, fetch_content, get_subagent_result).

  2. Dynamic — any extension can register a binding at runtime via a shared globalThis registry, supporting both install-order scenarios:

// In any extension's onLoad / session_start:
const registry = (globalThis as any).__piAnthropicBindings__;
if (registry) {
  registry.register({
    piName: "my_tool",
    canonicalName: "MyCanonical",
    schemaStrategy: "passthrough",
  });
}

The bridge exposes the registry on globalThis.__piAnthropicBindings__ at load time. If the bridge isn't installed, the property doesn't exist and the if (registry) guard is a no-op — zero coupling.

For order-independent install (bridge loads before or after other extensions), both sides use a symmetric emit-and-listen pattern:

  • Each extension that wants to register a binding calls declareToolBinding() at load time. This both emits the binding immediately AND subscribes to future request_bindings events so it can re-emit if the bridge loads later.
  • The bridge subscribes to register_binding events AND emits request_bindings on load so already-running extensions re-announce.
  • Result: regardless of load order, all bindings converge.

Precedence in resolveOutboundName (with adapters)

Once adapters are implemented, the outbound name resolution gains a new highest-priority step:

  1. binding registry (new)     ← adapter-equipped, schema translation
  2. CC_CANONICAL_NAMES          exact-case passthrough (name only)
  3. already mcp__*              passthrough
  4. NATIVE_ALIASES (legacy)     name-only alias
  5. FLAT_TO_MCP                 companion aliases
  6. PI_TO_CC_CANONICAL          core tool capitalization
  7. default                     mcp__pi__<name>

Testing strategy

Each binding adapter is a pure function (wireInput → piInput) tested in isolation — no wire, no hooks, no mocks:

test("blocked_domains → negative domainFilter entries", () => {
  expect(webSearchBinding.adaptInput({
    query: "foo", blocked_domains: ["reddit.com", "x.com"]
  })).toEqual({
    query: "foo", domainFilter: ["-reddit.com", "-x.com"]
  });
});

test("queries[] preferred over query", () => {
  expect(webSearchBinding.adaptInput({
    query: "A", queries: ["B", "C"]
  })).toEqual({ queries: ["B", "C"] });
});

Canonical schema drift

Anthropic may update tool schemas over time. Mitigations:

  • Keep canonicalSchema minimal — only map fields we actually translate. Fewer fields = less drift surface.
  • Pin the Claude Code docs version in a comment per binding.
  • Hybrid strategy is partially self-healing — unknown canonical fields flow through the adapter untouched (pi handler ignores them).

MCP naming convention

The MCP namespace convention is mcp__<server>__<tool> where __ (double underscore) is the segment delimiter. The tool-name portion — anything after the second __ — may contain single underscores freely:

mcp__pi__web_search               ✓  server=pi, tool=web_search
mcp__pi__get_subagent_result      ✓  server=pi, tool=get_subagent_result
mcp__exa__get_code_context        ✓  server=exa, tool=get_code_context

Even when the Claude Code endpoint appends its _ide suffix, the delimiter __ is preserved:

mcp__pi__web_search_ide           server=pi, tool=web_search_ide

The bridge's reverse-map lookup is map-based (not split("__")-based), so single underscores in tool bodies are never confused with segment delimiters. The _ide mangling is handled by a single suffix-strip retry.

Configuration

Most users don't need any configuration. The defaults ship sensible values:

  • CC_CANONICAL_NAMES — exact Claude Code canonical tool names that pass through unchanged.
  • PI_TO_CC_CANONICAL — pi lowercase core tools → canonical capitalization (readRead, writeWrite, bashBash, grepGrep).
  • FLAT_TO_MCP — well-known third-party companions (Exa, Firecrawl, Antigravity).
  • NATIVE_ALIASESempty by default. Populate in core-tools.ts if you want to alias a pi tool to its Anthropic-native equivalent (e.g. ask_userAskUserQuestion). Only enable when you've verified the schemas are compatible — Anthropic will deliver the native-shaped input.

Environment variables

Variable Purpose
PI_ANTHROPIC_MESSAGES_DEBUG_LOG=/tmp/pi-am.log Dump every outbound before/after payload and inbound rename to the given path. Logger failures never break requests.
PI_ANTHROPIC_MESSAGES_FORCE_CANONICAL=1 Force the gate open on any anthropic-messages session regardless of model id.
PI_ANTHROPIC_MESSAGES_DISABLE_CANONICAL=1 Force the gate closed even for Claude-matching sessions.

Install (local)

pi install -l /home/botond/pi-packages/pi-anthropic-messages

Or from GitHub (SSH):

pi install git@github.com:BlackBeltTechnology/pi-anthropic-messages.git

Dashboard recommended extensions

pi-agent-dashboard ships a curated manifest of extensions it integrates with — including this bridge (required for Claude-model anthropic-messages providers), @tintinweb/pi-subagents (Agent card UI), pi-flows (Flow dashboard), pi-web-access (web tools), and pi-agent-browser (browser automation).

The dashboard's first-launch wizard prompts for installation; its Packages tab shows a Recommended section with live install/active state; and a banner surfaces any missing required entry until it's resolved. See the dashboard's packages/shared/src/recommended-extensions.ts manifest for the authoritative list.

Typical installs:

# Required bridge (this package)
pi install git@github.com:BlackBeltTechnology/pi-anthropic-messages.git

# Strongly suggested
pi install npm:@tintinweb/pi-subagents
pi install git@github.com:BlackBeltTechnology/pi-flows.git
pi install npm:pi-web-access

# Optional
pi install npm:pi-agent-browser

Relationship to other packages

  • @earendil-works/pi-ai — the Anthropic provider in pi-ai already canonicalizes tool names (toClaudeCodeName) when it detects an OAuth token. Our bridge is idempotent with that behavior: for OAuth sessions pi-ai rewrites readRead before we see the payload, our exact-match check against CC_CANONICAL_NAMES finds Read, and we pass it through unchanged. For non-OAuth Claude anthropic-messages sessions (9Router sk-* key, pi-model-proxy, OAuth-subscription proxies, etc.), pi-ai does nothing and our bridge does the canonicalization. No double rename, no conflict.
  • @benvargas/pi-claude-code-use — superseded by this package for this repo. This package has a superset of behaviour (adds mcp__ prefixing for custom tools; gates on Claude-model detection; does not filter tools).
  • pi-flows — its subagent-side tool prefixing (extensions/flow-engine/tool-prefix.ts, mcp__flows__) handles in-process SDK sessions; this package handles the main session payload. Both complement each other.
  • pi-agent-dashboard — pulls this bridge in as a required recommended extension for any Claude-model anthropic-messages provider setup.

Releasing

Releases are cut via the release-cut skill and revoked via release-revoke; both walk through pre-flight, CHANGELOG curation, version bump, tag, and push, with the actual npm publish happening on GitHub Actions (.github/workflows/release.yml). The Release workflow can also be triggered by workflow_dispatch from the Actions UI with a version input, which performs the bump-commit-tag-push on the runner.

Exported API

For consumers that need the authoritative lists without duplication:

import {
  CC_CANONICAL_NAMES,
  PI_TO_CC_CANONICAL,
  NATIVE_ALIASES,
  FLAT_TO_MCP,
  DEFAULT_MCP_PREFIX,
  resolveOutboundName,
  transformPayload,
  buildReverseMap,
  lookupReverse,
  renameToolCallsInPlace,
  isClaudeAnthropicMessages,
} from "@blackbelt-technology/pi-anthropic-messages";