@counterposition/skill-pi 0.87.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -103,6 +103,7 @@ Useful agent events:
103
103
  - `before_provider_headers` (Pi 0.80.4) — mutate `event.headers` in place after outgoing HTTP headers are assembled: set a key to a string to add/override, `null` to delete. Runs once per provider request; retries reuse the same headers
104
104
  - `before_provider_request`
105
105
  - `after_provider_response` — inspect the provider HTTP status and headers before stream consumption
106
+ - `provider_stream_event` (Pi 0.99.0) — notification-only view of each parsed provider stream event before Pi normalizes it (`provider`, `api`, `model`, `data`). Treat `data` as read-only; handlers are awaited in stream order, so keep them fast. Not persisted
106
107
  - `model_select`
107
108
  - `thinking_level_select` — observe interactive thinking-level changes
108
109
  - `ui_prompt_start` / `ui_prompt_end` (Pi 0.84.4) — distinguish active agent work from time spent waiting on blocking `ctx.ui` prompts (`kind`: `select`/`confirm`/`input`/`editor`/`custom`)
@@ -121,6 +122,8 @@ Useful tool events:
121
122
  - `tool_result`
122
123
  - `tool_execution_end`
123
124
 
125
+ Calls another tool makes through `ctx.executeTool()` (codemode scripts, orchestrating tools) fire the same tool events, so permission gates see them; see [Nested Calls and Permissions](codemode.md#nested-calls-and-permissions).
126
+
124
127
  Since Pi 0.86.0, tool-call `arguments` and result `details` must be JSON-compatible values (no `Date`, `Map`, class instances, `undefined` array slots); use `details: undefined` when there is nothing structured to keep. Tools registered without a parameter schema are rejected at registration.
125
128
 
126
129
  Tool results may include `terminate: true` to end the current tool batch without an automatic follow-up LLM turn — useful for tools that produce a structured final answer (see `examples/extensions/structured-output.ts` in the Pi repo). Since Pi 0.84.1, blocked `tool_call` results may also return `terminate: true`; the agent stops early only when every finalized result in the batch is terminating. Tools that make nested LLM calls can return their combined `Usage` as `usage` on the tool result — Pi persists it and includes it in footer, `/session`, and RPC session totals (Pi 0.81.0), and `tool_result` handlers may inspect or replace that value.
@@ -137,6 +140,7 @@ Tool results may include `terminate: true` to end the current tool batch without
137
140
  - `ctx.sessionManager` (read-only; `buildContextEntries()` returns active-branch entries with compaction applied, Pi 0.80.4)
138
141
  - `ctx.modelRegistry` / `ctx.model` / `ctx.thinkingLevel` / `ctx.scopedModels` (Pi 0.83.0) — `ctx.modelRegistry` is the synchronous extension-facing facade; its `refresh()` became `Promise<void>` in Pi 0.80.8 (await it before synchronous registry reads). Since Pi 0.84.x it also exposes `getProvider(id)` (effective pi-ai provider) and `getProviderAuth(id)` (API key, headers, base URL, provider-scoped env — no loaded model required). `ctx.scopedModels` is the read-only session model scope resolved from `--models`/`enabledModels` (`{ model, thinkingLevel? }[]`, empty when unscoped); prefer it over enumerating `getAvailable()` for model pickers
139
142
  - `ctx.modelRegistry.stream()` / `streamSimple()` / `complete()` (Pi 0.86.0) — nested model calls through configured providers with request-time auth; prefer `streamSimple()` for provider-neutral calls, pass `ctx.signal`, and report the combined `usage` on the tool result
143
+ - `ctx.modelRegistry.findOfType(type, provider, id)`, `getModelsOfType()`, `getAvailableOfType()`, and `classify(model, { state, questions }, { signal })` (Pi 0.99.0), plus `generateImages(model, { input }, options?)` (Pi 1.0.0) — classifier and image models live beside chat models; neither call rejects, so check `result.stopReason`, and report the returned `usage` on the tool result (see `references/providers.md`)
140
144
  - `ctx.hasUI` — `true` in TUI and RPC modes
141
145
  - `ctx.signal` — the active agent abort signal (or `undefined` when idle); pass it to `fetch`/model calls for abort-aware nested work
142
146
  - `ctx.isIdle()` / `ctx.hasPendingMessages()`
@@ -146,6 +150,8 @@ Tool results may include `terminate: true` to end the current tool batch without
146
150
  - `ctx.getSystemPrompt()`
147
151
  - `ctx.isProjectTrusted()` — the effective project trust decision, including temporary `--approve`/`--no-approve` decisions
148
152
 
153
+ Tool `execute()` receives `ExtensionToolContext`, which adds `ctx.tools` (the tools this call can reach) and `ctx.executeTool(name, args, { signal?, onUpdate? })` (Pi 0.99.0). `executeTool()` runs validation and the `tool_call`/`tool_result` hooks like a model call and never rejects: unknown tools, validation errors, blocks, and thrown errors come back as `{ isError: true }`, including after an abort. The signal defaults to the calling tool's. Nested usage is added to the calling tool's result, so report only the tool's own `usage`.
154
+
149
155
  Use `CONFIG_DIR_NAME` (exported from `@earendil-works/pi-coding-agent`) instead of hardcoding `.pi` when building project config paths, e.g. `join(ctx.cwd, CONFIG_DIR_NAME, "my-extension.json")`.
150
156
 
151
157
  Command handlers receive `ExtensionCommandContext`, which extends the above with session-control methods that would deadlock from event handlers: `ctx.getSystemPromptOptions()` (inspect the base system-prompt inputs), `ctx.waitForIdle()`, `ctx.newSession()`, `ctx.fork()`, `ctx.switchSession()`, `ctx.navigateTree()`, and `ctx.reload()`.
@@ -181,12 +187,27 @@ Important rules:
181
187
 
182
188
  1. Truncate large output.
183
189
  2. Respect `signal.aborted`.
184
- 3. Throw on failure instead of returning fake success.
185
- 4. Use the file-mutation queue for write/edit style tools.
190
+ 3. Throw on failure instead of returning fake success, or return `isError: true` when the failure still carries data. A returned object without `isError` counts as success.
191
+ 4. Use the file-mutation queue (`withFileMutationQueue()`) for write/edit style tools.
186
192
  5. Put reconstructable state in `details`; it persists in session history.
187
193
 
194
+ ## Tool Exposure & Structured Results
195
+
196
+ Pi 0.99.0 added fields for tools that other tools, codemode scripts, and permission extensions consume ([docs](https://github.com/earendil-works/pi/blob/v1.0.0/packages/coding-agent/docs/extensions.md#tool-exposure), [types](https://github.com/earendil-works/pi/blob/v1.0.0/packages/coding-agent/src/core/extensions/types.ts)):
197
+
198
+ - `outputSchema` (TypeBox) plus `structuredContent` on every result: the model still reads `content`; codemode scripts receive `structuredContent` instead of text. Without `outputSchema`, scripts get the text. `structuredContent` must be JSON-compatible. A `tool_result` handler that replaces `content` must also return `structuredContent`, or it is dropped.
199
+ - `isError: true` on a returned result: the model sees an error, while `details` and `structuredContent` are kept for the UI and scripts.
200
+ - `exposure`: `direct` (default), `model-only`, `codemode`, `deferred`, or `hidden`. `direct` and `model-only` tools activate on registration; the others do not (`defaultActive: false` also keeps a `direct` tool inactive until `--tools`/`defaultTools`/`setActiveTools()` names it). Tools cannot be unregistered: re-register with `exposure: "hidden"` to withdraw one. The table of what each value declares and allows is in `references/codemode.md`.
201
+ - `namespace: { name, description?, instructions? }` groups related tools, as MCP servers do. Codemode lists a namespace under one heading with its short `description`. `instructions` (Pi 0.99.2) holds longer usage guidance that is not listed; scripts read it with `describeNamespace(name)`.
202
+ - `annotations`: `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`, with MCP meanings. Missing hints mean "not read-only, may be destructive, open world". They are unverified; mark writes neither read-only nor casually idempotent.
203
+ - `prepareLoadout(loadout)`: for tools that orchestrate others. Runs whenever the active tools change with `declared`, `callable`, and `registered` tools, and returns replacement `descriptions` and `hiddenDeclarations` (active tools left out of requests but still callable). The built-in `codemode` uses only this, `exposure`, and `ctx.executeTool()`.
204
+
205
+ `pi.getAllTools()` reports each tool's `exposure`, `namespace`, and `annotations`. `pi.setActiveTools()` ignores unknown and `hidden` names. Compilable examples: `examples/structured-tool.ts` and `examples/nested-tools.ts`.
206
+
188
207
  ## Dynamic Tool Loading
189
208
 
209
+ For tools the model should find on demand, prefer `exposure: "deferred"` with the built-in `tool_search` (Pi 0.99.0), or `exposure: "codemode"` for tools scripts call. `tool_search` is inactive by default: enable it with `"defaultTools": ["+tool_search"]` or `--tools`, or let a connected MCP server with `deferred` exposure turn it on. Without it, the model can reach `deferred` tools only through codemode. The manual loader pattern below still works.
210
+
190
211
  Pi 0.80.7 lets an extension register many tools while keeping only a small initial set active, then add more during execution — cache-friendly on models with native deferred loading. Lifecycle:
191
212
 
192
213
  1. Register every tool with `pi.registerTool()` (all appear in `pi.getAllTools()`).
@@ -206,7 +227,7 @@ Cache tips: keep the loader active for the whole session; add rather than replac
206
227
  - `{ type: "grammar", variants: { openai_lark?: string, openai_regex?: string } }` — OpenAI Lark/regex grammar encodings
207
228
  - Set `false` to explicitly disable for that tool
208
229
 
209
- Model capability metadata gates what is actually sent: `compat.supportsStrictTools` / Anthropic built-ins enable strict JSON-schema tools, and `compat.supportsOpenAIGrammarTools` marks endpoints that accept Lark/regex grammar tools (enabled in generated metadata for GPT-5+ across OpenAI, Codex, Azure, GitHub Copilot, opencode, Cloudflare AI Gateway). Unsupported combinations fall back to normal function tools rather than failing. Since Pi 0.86.0, strict-prefer JSON-schema sampling is on by default for the built-in `read`, `bash`, `powershell`, `edit`, and `write` tools (no `PI_EXPERIMENTAL` needed); re-register a definition with `constrainedSampling: false` to opt out.
230
+ Model capability metadata gates what is actually sent: `compat.supportsStrictTools` / Anthropic built-ins enable strict JSON-schema tools, and `compat.supportsOpenAIGrammarTools` marks endpoints that accept Lark/regex grammar tools (enabled in generated metadata for GPT-5+ across OpenAI, Codex, Azure, GitHub Copilot, opencode, Cloudflare AI Gateway). With `strict: "prefer"`, unsupported combinations fall back to normal function tools; with `strict: "require"`, the request fails when the model has no strict tools or the schema uses keywords strict mode rejects. Since Pi 0.99.2 Anthropic strict tools also reject keywords such as `minimum`/`maximum`, so such `prefer` tools are sent non-strict. Since Pi 0.86.0, strict-prefer JSON-schema sampling is on by default for the built-in `read`, `bash`, `powershell`, `edit`, and `write` tools (no `PI_EXPERIMENTAL` needed); re-register a definition with `constrainedSampling: false` to opt out.
210
231
 
211
232
  ## Display Transformers
212
233
 
@@ -311,6 +332,14 @@ pi.on("session_start", async (_event, ctx) => {
311
332
  });
312
333
  ```
313
334
 
335
+ ## MCP Servers
336
+
337
+ `pi.registerMcpServer(name, config)` adds a server for the current session with the `mcp.json` entry shape; `pi.unregisterMcpServer(name)` closes it. Registrations are not saved, and an `mcp.json` server with the same name wins. Extensions that implement their own MCP client read `pi.getMcpServers()` on `session_start` and handle `mcp_servers_change`. See `references/mcp.md`.
338
+
339
+ ## Virtual Models
340
+
341
+ `pi.registerVirtualModel({ provider, id, name, thinkingLevels?, route(request, ctx) })` (experimental, Pi 0.99.0) adds a selectable model that picks a physical model and thinking level for every request. See `references/providers.md#virtual-models`.
342
+
314
343
  ## Provider Integration
315
344
 
316
345
  Extensions can register or override providers:
@@ -341,6 +370,8 @@ pi.unregisterProvider("my-provider");
341
370
 
342
371
  Dynamic providers can implement `refreshModels(context)` (Pi 0.80.8) for model discovery: Pi calls it during catalog refresh (`/model`, `pi update --models`) and publishes the returned list; its models replace extension-provided `models`. Since Pi 0.84.0 the context is read-snapshot + generation-checked publication: read `context.stored` (the persisted provider snapshot) instead of `context.store`, and persist through `context.publish({ update?, persist? })` — `persist` omitted leaves storage unchanged, a `ModelsStoreEntry` writes it, `persist: null` deletes it. Config-form callbacks that only return models remain unchanged.
343
372
 
373
+ A config-form `models` list replaces the provider's models across chat, image, and classifier operations (Pi 0.99.0). Omitted `type` means `"chat"`; image and classifier entries need an explicit `type` plus implementations keyed by `api` through `images`/`classifiers` (see `references/providers.md`).
374
+
344
375
  Since Pi 0.81.0, extensions can also register a complete pi-ai `Provider` via `pi.registerProvider(createProvider({...}))` — native auth (`login`/`resolve`), `getModels`, `refreshModels`, `filterModels`, and custom streaming. The registered provider becomes the composition base; `models.json` overrides still apply above it. Prefer this when you need real authentication or stream behavior rather than just a model list. The legacy config form (`name`, `baseUrl`, `apiKey`, `models`, `oauth`, `streamSimple`) remains supported. OAuth `refreshToken(credentials, signal)` callbacks must accept and honor the abort signal since Pi 0.84.0.
345
376
 
346
377
  If you need auth for a specific model request, use:
@@ -355,7 +386,9 @@ Custom `streamSimple` implementations receive a normalized `TranscriptContext` s
355
386
 
356
387
  ## Handy Patterns
357
388
 
358
- - Permission gates via `tool_call`
389
+ - Permission gates via `tool_call` (they also see nested calls from codemode; see `references/codemode.md`)
390
+ - Data tools for codemode via `outputSchema` + `structuredContent`
391
+ - Orchestrating tools via `ctx.executeTool()` with `exposure: "model-only"`
359
392
  - Prompt injection via `before_agent_start` (`systemPromptOptions.sections`)
360
393
  - Model-context pruning via `context`, or durable omission via `context_edit` entries from `turn_end`/`agent_before_settle`
361
394
  - Branch or compaction customization via `session_before_tree` / `session_before_compact`
@@ -0,0 +1,137 @@
1
+ # MCP Servers
2
+
3
+ Pi connects to MCP servers through a built-in extension. There is no package to install. Checked against Pi v1.0.0: [mcp.md](https://github.com/earendil-works/pi/blob/v1.0.0/packages/coding-agent/docs/mcp.md), [cli.md#mcp-commands](https://github.com/earendil-works/pi/blob/v1.0.0/packages/coding-agent/docs/cli.md#mcp-commands), [extensions.md#mcp-servers](https://github.com/earendil-works/pi/blob/v1.0.0/packages/coding-agent/docs/extensions.md#mcp-servers).
4
+
5
+ ## Configure
6
+
7
+ | File | Scope |
8
+ |------|-------|
9
+ | `~/.pi/agent/mcp.json` | Global. Put personal servers and servers with credentials here |
10
+ | `.pi/mcp.json` | Project. Read only after the project is trusted, because stdio servers run commands |
11
+
12
+ A project entry replaces a global entry with the same name. The format is the usual `mcpServers` object, so Claude Desktop, Claude Code, and Cursor entries copy over unchanged:
13
+
14
+ ```json
15
+ {
16
+ "mcpServers": {
17
+ "filesystem": {
18
+ "command": "npx",
19
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "."]
20
+ },
21
+ "docs": {
22
+ "url": "https://example.com/mcp",
23
+ "headers": { "Authorization": "Bearer ${DOCS_TOKEN}" },
24
+ "description": "Search and read the product documentation",
25
+ "exposure": "direct"
26
+ }
27
+ }
28
+ }
29
+ ```
30
+
31
+ - stdio: `command` (one executable, not a shell string), `args`, `env`, `cwd`. Relative `cwd` resolves against the session directory; a leading `~/` works in `command`, args, and `cwd`.
32
+ - HTTP: `url`, `headers`, `oauth`. Only streamable HTTP; `"type": "sse"` is rejected (try the server's `/mcp` endpoint instead of `/sse`).
33
+ - `type` is optional (`stdio`, `http`, or `streamable-http`); `command` implies stdio and `url` implies HTTP.
34
+ - `env` and `headers` values accept `${NAME}` and `!command`. A command must produce the whole value: `"Authorization": "!echo Bearer $(gh auth token)"`.
35
+ - `timeout` is seconds per request (default 60); progress notifications reset it. `enabled: false` keeps the entry without connecting.
36
+ - `description` (Pi 0.99.2): one sentence on what the server offers. It lists the server in the system prompt's `mcp_servers` section, ranks its tools in tool search, and is returned by codemode's `describeNamespace()`. Without it, Pi uses the first line of the server instructions once connected.
37
+ - Server names use letters, digits, `_`, `-`. Tools are named `mcp__<server>__<tool>`, with every character other than letters, digits, and `_` replaced by `_` (Pi 0.99.2: `mcp__my-server__x` is now `mcp__my_server__x`; update permission rules that name the old spelling). Tools of one server whose names then collide all get a hash suffix. Server names that differ only in `-` and `_` count as the same server; a second one is rejected.
38
+ - Invalid entries are skipped and reported; other servers still connect.
39
+
40
+ Converting other clients: VS Code uses `servers` + `inputs` (move under `mcpServers`, replace `${input:...}` with `${NAME}`); Codex uses TOML `[mcp_servers.<name>]` (same fields as JSON); opencode uses `"type": "local"` with an array `command` (split into `command`/`args`), `environment` for `env`, and `{env:NAME}` for `${NAME}`.
41
+
42
+ ## Set Up From a Shell
43
+
44
+ These commands work outside a session, so an agent can run them through `bash`:
45
+
46
+ ```bash
47
+ pi mcp add filesystem -- npx -y @modelcontextprotocol/server-filesystem .
48
+ pi mcp add docs --url https://example.com/mcp --bearer-token-env-var DOCS_TOKEN --exposure direct --description "Product docs"
49
+ pi mcp add -l tools --env API_KEY='${TOOLS_KEY}' -- uvx tools-mcp # -l writes .pi/mcp.json
50
+ pi mcp list # connects to every enabled server; exits 1 while anything is wrong
51
+ pi mcp login sentry # OAuth: opens the browser and waits for the user to approve
52
+ pi mcp remove docs # keeps stored OAuth credentials
53
+ pi mcp logout sentry
54
+ ```
55
+
56
+ `pi mcp add` also takes `--oauth-client-id`, `--oauth-client-secret`, `--oauth-callback-port`, and `--oauth-client-name`. It does not connect; verify with `pi mcp list`. A running session picks up added or changed servers after `/reload` or a new session.
57
+
58
+ In a session, `/mcp` opens the server manager (state, tools, errors, sign in/out, reconnect, exposure, enable/disable). Changes are written back to the `mcp.json` that defines the server. Outside the TUI, `/mcp` prints status and `/mcp login|logout|reconnect <server>` run directly.
59
+
60
+ Pi connects every enabled server in the background when a session starts (Pi 0.99.2). The first prompt waits up to 10 seconds only for servers with `direct` tools. Other servers are waited for when needed: a codemode script waits for the servers it names (`mcp__<server>`), and `searchTools()`, `ALL_TOOLS`, `tool_search`, and the resource tools wait for all of them. Server log notifications go to `~/.pi/agent/mcp.log`.
61
+
62
+ ### Authentication
63
+
64
+ - OAuth is used only for HTTP servers whose config has no `Authorization` header. Tokens live in `~/.pi/agent/mcp-auth.json`, stored per server name and URL (Pi 1.0.0), so two names for the same URL sign in with different accounts.
65
+ - `oauth: { clientId?, clientSecret?, callbackPort?, callbackUrl?, scope?, clientName?, authServerMetadataUrl? }`:
66
+ - `clientId`/`clientSecret` set a pre-registered client for servers without dynamic client registration.
67
+ - `callbackPort` alone gives `http://127.0.0.1:<port>/callback`; a `callbackUrl` (HTTP on a loopback host) without a port gets `callbackPort` or a free one.
68
+ - `scope`: space-separated scopes to request when the server does not advertise the ones it needs.
69
+ - `clientName` (Pi 0.99.2): the name sent at client registration (default `pi`), for servers that only accept known clients, e.g. `"Claude Code"`. Sign out first to register again under a new name.
70
+ - `authServerMetadataUrl` (Pi 1.0.0): the authorization server metadata document to use instead of discovery, for servers that advertise a wrong authorization server or none. Trusted as configured; HTTPS except on loopback.
71
+ - Pi rejects an authorization response whose `iss` names another authorization server (RFC 9207), and step-up sign-in after `insufficient_scope` keeps the scopes already granted (Pi 1.0.0).
72
+ - `"auth": { "provider": "<provider>" }` (Pi 0.99.2) sends a `/login` provider's current token as the bearer token instead of MCP OAuth, read on every request. Allowed only in the global `mcp.json` and from extensions, and HTTPS except on loopback. After a Radius sign-in, `/login` offers to add the Radius MCP server this way.
73
+
74
+ ## Exposure
75
+
76
+ `exposure` decides how the model reaches a server's tools:
77
+
78
+ | Value | Declared to the model | Callable from codemode | Notes |
79
+ |-------|----------------------|------------------------|-------|
80
+ | `codemode` (default) | No | Yes, found with `searchTools()`, `describeNamespace()`, or `ALL_TOOLS`; not listed in the `codemode` description | Pi activates `codemode` when such a server connects |
81
+ | `deferred` | After `tool_search` loads them | Yes | Pi activates `tool_search`; for large servers without codemode |
82
+ | `direct` | Yes, like built-in tools | Yes | |
83
+ | `hidden` | No | No | Registered but unreachable |
84
+
85
+ `toolExposure` overrides single tools by name or `*` pattern (exact name wins, then first matching pattern):
86
+
87
+ ```json
88
+ {
89
+ "mcpServers": {
90
+ "github": {
91
+ "url": "https://api.githubcopilot.com/mcp/",
92
+ "exposure": "deferred",
93
+ "toolExposure": { "search_code": "direct", "get_*": "codemode", "delete_*": "hidden" }
94
+ }
95
+ }
96
+ }
97
+ ```
98
+
99
+ `codemode-deferred` is an alias for `codemode` since Pi 0.99.2. Servers with `codemode` or `deferred` tools are listed in an `mcp_servers` system prompt section with how their tools are reached and a one-line summary. Pi updates it when a prompt starts and appends a changed section to the conversation, so neither tool descriptions nor the cached prefix change when servers connect.
100
+
101
+ Undeclared tools (`codemode`, `deferred`) are reachable through either tool: codemode scripts can call a `deferred` server's tools, and `tool_search` can load a `codemode` server's tools. Set top-level `"autoEnableCodemode": false` in `mcp.json` to stop Pi from activating `codemode`. Pi warns once when neither `codemode` nor `tool_search` is active, because the tools are then unreachable.
102
+
103
+ ## Results
104
+
105
+ - Text results over 20KB reach the model with the middle cut out; the full text goes to a temp file named in the result.
106
+ - Codemode scripts receive the whole `CallToolResult` (`content`, `structuredContent`, `isError`). A result with `isError` resolves in scripts and is reported to the model as an error for direct calls. `image(result.content[0])` forwards an image block. Server instructions are not part of any tool description; scripts read them with `describeNamespace("mcp__<server>")`.
107
+ - Servers with resources add `list_mcp_resources`, `list_mcp_resource_templates`, and `read_mcp_resource` (marked read-only).
108
+ - Tool calls are never retried (the server may have run them); resource reads retry once on 408/429/5xx.
109
+
110
+ ## Permissions
111
+
112
+ Every MCP call goes through Pi's tool pipeline, so `tool_call` and `tool_result` handlers (permission gates included) see MCP tools. Calls from codemode scripts are nested calls; see [Nested Calls and Permissions](codemode.md#nested-calls-and-permissions). `pi.getAllTools()` reports the server's `readOnlyHint`, `destructiveHint`, `idempotentHint`, and `openWorldHint` annotations. They are unverified hints; see [Tool Exposure](extensions.md#tool-exposure--structured-results).
113
+
114
+ ## Servers From Extensions
115
+
116
+ ```typescript
117
+ pi.registerMcpServer("jira", { url: "https://mcp.example.com/jira", exposure: "codemode", description: "Jira issues" });
118
+ pi.unregisterMcpServer("jira");
119
+ ```
120
+
121
+ - Same config shape as an `mcpServers` entry. Registrations are not saved: register on every load.
122
+ - Registered during load: connects on `session_start` with the `mcp.json` servers. Registered later: connects right away.
123
+ - An `mcp.json` server with the same name wins. Another extension's name, an invalid name, or an invalid config throws.
124
+ - `pi mcp` shell commands do not load extensions and only see `mcp.json` servers.
125
+ - Alternative MCP extensions read registrations with `pi.getMcpServers()` on `session_start` and handle `mcp_servers_change`.
126
+
127
+ ## Turning It Off or Replacing It
128
+
129
+ - `"extensions": ["-builtin:mcp"]` in settings, or `pi config` → Built-in, disables the built-in support. `pi mcp` shell commands still work.
130
+ - `--no-extensions` disables built-ins too; `pi -ne -e builtin:mcp` keeps only MCP.
131
+ - An extension that registers the `/mcp` command (for example `pi-mcp-adapter`) replaces the built-in support: `mcp.json` is then not read in sessions.
132
+
133
+ ## SDK
134
+
135
+ SDK sessions do not load built-in extensions. Add `createMcpExtension()`, plus `createCodemodeExtension()` for `codemode` servers and `createToolSearchExtension()` for `deferred` servers, then call `session.bindExtensions()` so `session_start` connects the servers. See `references/sdk.md`.
136
+
137
+ The standalone client is published as `@earendil-works/pi-mcp` (stdio and streamable HTTP transports, OAuth, an in-memory testing transport). It has no Pi session dependency.
@@ -94,16 +94,21 @@ If there is no `pi` manifest, Pi auto-discovers:
94
94
 
95
95
  ## Dependencies
96
96
 
97
+ Checked against Pi v1.0.0 [packages.md](https://github.com/earendil-works/pi/blob/v1.0.0/packages/coding-agent/docs/packages.md#declare-dependencies).
98
+
97
99
  - Normal runtime dependencies belong in `dependencies`
98
- - Pi core libraries should be peer deps with `"*"` ranges:
100
+ - Pi supplies these to extensions; declare them as peer deps with `"*"` ranges and never bundle them:
99
101
  - `@earendil-works/pi-ai`
100
102
  - `@earendil-works/pi-agent-core`
101
103
  - `@earendil-works/pi-coding-agent`
102
104
  - `@earendil-works/pi-tui`
103
105
  - `typebox`
104
- - Pin dev dependencies to a current Pi line (e.g. `^0.84.2`) so typecheck/tests validate against what users run; keep peers `"*"` so installs stay compatible across Pi versions
105
- - Other Pi packages must be bundled explicitly
106
- - Git package installs run `npm install --omit=dev` — runtime needs go in `dependencies`, not `devDependencies`
106
+ - Do not list those host-provided packages in `dependencies`. A physical copy can bypass Pi's extension module mapping and create duplicate classes and registries; since Pi 0.99.0 Pi reports an extension warning for this manifest.
107
+ - Pi suppresses automatic peer installation for managed npm packages and for git packages installed with npm (`--omit=dev --legacy-peer-deps`), pnpm (`--prod`, no auto-installed peers), or Bun (`--omit=dev --omit=peer`). Local path packages are not installed or modified; their dependency tree is the author's responsibility.
108
+ - Git package installs omit dev dependencies — runtime needs go in `dependencies`, not `devDependencies`
109
+ - Installed packages load with separate module roots. Do not rely on two packages sharing one dependency instance, or on one package resolving another package's undeclared dependency.
110
+ - Other Pi packages used as dependencies must be included in the published tarball and referenced through their `node_modules` resource paths.
111
+ - Pin dev dependencies to the Pi line you test against (e.g. `^1.0.0`) so typecheck/tests validate against what users run; keep peers `"*"` so installs stay compatible across Pi versions. `"*"` does not prove compatibility: document the minimum supported Pi version and feature-check newer APIs (for example `ctx.executeTool`, `pi.registerMcpServer`) if you support older hosts.
107
112
 
108
113
  ## Package Filtering
109
114
 
@@ -134,4 +139,5 @@ Rules:
134
139
 
135
140
  - Project settings override global settings for the same package identity — unless the project entry has `autoload: false`, in which case it is applied as a delta over the global entry (Pi 0.80.4)
136
141
  - Identity is package name for npm, repo URL for git, and resolved absolute path for local sources
137
- - `pi config` can enable or disable package resources after installation; since Pi 0.80.4 it manages global vs project-local scopes (Tab to switch, `pi config -l` to start in project mode with inherited global resources dimmed)
142
+ - `pi config` can enable or disable package resources after installation; since Pi 0.80.4 it manages global vs project-local scopes (Tab to switch, `pi config -l` to start in project mode with inherited global resources dimmed). Since Pi 0.99.0 its Built-in section also disables the built-in `mcp`, `llama.cpp`, `codemode`, and `tool-search` extensions, stored as `-builtin:<name>` in `extensions`
143
+ - The `codemode`, `tool-search`, and `mcp` built-ins are replaceable: an extension that registers one of their tools, commands, or flags (for example `/mcp`, `codemode`, or `tool_search`) replaces that built-in, and Pi warns
@@ -19,6 +19,11 @@ Practical recipes for extending Pi. Each pattern is a self-contained example.
19
19
  13. [File Watcher / Trigger](#file-watcher--trigger)
20
20
  14. [Input Transform](#input-transform)
21
21
  15. [Tool Override](#tool-override)
22
+ 16. [Structured Tool](#structured-tool)
23
+ 17. [Nested Tool Calls](#nested-tool-calls)
24
+ 18. [Classifier Gate](#classifier-gate)
25
+
26
+ Patterns 16–18 are full files under `examples/` that type-check against Pi 1.0.0 in this skill's package; read them rather than retyping.
22
27
 
23
28
  ---
24
29
 
@@ -43,6 +48,8 @@ export default function (pi: ExtensionAPI) {
43
48
  }
44
49
  ```
45
50
 
51
+ The handler also runs for `bash` calls made by codemode scripts; see [Nested Calls and Permissions](codemode.md#nested-calls-and-permissions).
52
+
46
53
  ## Protected Paths
47
54
 
48
55
  Block writes to sensitive files:
@@ -313,3 +320,30 @@ pi.on("tool_call", async (event, ctx) => {
313
320
  }
314
321
  });
315
322
  ```
323
+
324
+ ## Structured Tool
325
+
326
+ `examples/structured-tool.ts` registers a read-only data tool:
327
+
328
+ - `outputSchema` plus `structuredContent` on every result, so codemode scripts get `{ files, missing }` as an object while the model reads a text summary.
329
+ - `annotations: { readOnlyHint: true, openWorldHint: false }` for permission extensions, and a `namespace` for grouping in the `codemode` description.
330
+ - `isError: true` when no file was found: the model sees an error, scripts still get the data.
331
+ - Only `ENOENT` is treated as "missing"; other errors are rethrown.
332
+
333
+ ## Nested Tool Calls
334
+
335
+ `examples/nested-tools.ts` registers `read_many`, which reads several files in parallel through `ctx.executeTool("read", ...)`:
336
+
337
+ - `exposure: "model-only"`: the model can call it, other tools and codemode cannot, so it cannot recurse.
338
+ - Checks `ctx.tools` first: an inactive `direct` tool is not callable.
339
+ - Each nested call runs every `tool_call` gate (see [Nested Calls and Permissions](codemode.md#nested-calls-and-permissions)). `executeTool()` never rejects, and aborted calls come back as `isError`, so the tool calls `signal?.throwIfAborted()` after the batch to report the abort as an abort rather than as failed reads.
340
+ - Streams progress through `onUpdate`.
341
+
342
+ ## Classifier Gate
343
+
344
+ `examples/classifier-gate.ts` asks a classifier model whether a `bash` command is destructive before it runs:
345
+
346
+ - Finds Jev with `ctx.modelRegistry.findOfType("classifier", "typesafe", "jev-latest")` and calls `ctx.modelRegistry.classify(..., { signal: ctx.signal })`.
347
+ - `classify()` never rejects: it checks `stopReason` (`"aborted"` blocks, `"error"` falls through to confirmation) and validates the `bool` answer's probability.
348
+ - Fails closed: a missing model, an error, an out-of-range answer, or a probability above the strict `MAX_ALLOWED_PROBABILITY` (0.2; tune it) asks the user, and blocks when `ctx.hasUI` is false.
349
+ - Uses `satisfies ClassifierContext["questions"]` so the question literal keeps its narrow types.
@@ -6,12 +6,13 @@ Pi supports subscription-based providers via OAuth, API-key providers via env va
6
6
 
7
7
  Use `/login` in interactive mode, then select a provider. The `/login` selector is fuzzy-searchable and shows where each entry's auth comes from (`--api-key`, env var, custom provider) without leaking the secret.
8
8
 
9
- - Anthropic Claude Pro / Max — third-party usage draws from extra usage and is billed per token (suppress the warning via `warnings.anthropicExtraUsage`)
10
- - OpenAI ChatGPT Plus / Pro (Codex) — `/login` defaults to browser auth but can use a device-code flow for headless environments
9
+ - Anthropic Claude Pro / Max — third-party usage draws from extra usage and is billed per token (suppress the warning via `warnings.anthropicExtraUsage`). Since Pi 1.0.0 `/login anthropic` offers browser login (default) or **copy code** login, which shows the authorization code on Anthropic's page for pasting into Pi when the browser runs on another machine
10
+ - OpenAI with a ChatGPT subscription (Pi 0.99.0) — `/login openai` offers **Sign in with ChatGPT**, an OAuth login that uses the subscription with the OpenAI API on the regular `openai` provider. Pi stores a stable `deviceId` for it in global settings. Subscription usage-limit errors are not retried
11
+ - OpenAI Codex (legacy) — the older `openai-codex` provider, renamed in Pi 0.99.0 and superseded by Sign in with ChatGPT. Its `/login` still offers browser auth or a device-code flow; its default model is `gpt-6.1-sol` (Pi 0.99.1). Prefer Sign in with ChatGPT on `openai` for new setups
11
12
  - GitHub Copilot
12
13
  - OpenRouter (Pi 0.82.0) — `/login openrouter` runs a PKCE flow that mints a user-controlled API key billed from OpenRouter credits; on headless/SSH hosts paste the redirect URL or authorization code into the prompt (Pi 0.83.0). `OPENROUTER_API_KEY` still works via **Use an API key**
13
14
  - xAI (Grok/X subscription, Pi 0.80.8) — `/login xai` then **Use a subscription** (device-code OAuth); `XAI_API_KEY` remains available via **Use an API key**
14
- - Radius (Pi 0.80.8) — a dynamic `pi-messages` gateway; `/login radius` stores OAuth tokens, and custom Radius gateways can be declared in `models.json` with `"oauth": "radius"` plus a gateway `baseUrl`. Since Pi 0.86.0 a public Radius catalog ships for immediate/offline selection (cached and live gateway catalogs overlay it); custom gateways use their own catalog
15
+ - Radius (Pi 0.80.8) — a dynamic `pi-messages` gateway; `/login radius` stores OAuth tokens, and custom Radius gateways can be declared in `models.json` with `"oauth": "radius"` plus a gateway `baseUrl`. Since Pi 0.86.0 a public Radius catalog ships for immediate/offline selection (cached and live gateway catalogs overlay it); custom gateways use their own catalog. Since Pi 1.0.0 `/login` lists **Sign in with Radius** at the top level and, after sign-in, offers to add the Radius MCP server to the global `mcp.json` with `"auth": { "provider": "radius" }`
15
16
  - Meta Muse (Pi 0.86.1) — `/login meta` with automatic Model API key refresh; `META_API_KEY` also works
16
17
 
17
18
  `/login <provider>` with autocomplete works since Pi 0.80.4, and login methods are provider-owned since 0.80.8 (registered pi-ai providers expose their own auth options and status in `/login`). Use `/logout` to clear stored OAuth credentials. Pi 0.71.0 removed built-in Google Gemini CLI and Google Antigravity providers. `pi auth check [provider|model]` (Pi 0.84.1) preflights credential readiness; `pi auth print-api-key` / `pi auth print-bearer-token` (Pi 0.83.0) export resolved credentials to external clients, refreshing OAuth as needed.
@@ -43,6 +44,7 @@ Since Pi 0.80.8, built-in catalogs are complemented by dynamic ones: `/model` re
43
44
  | OpenCode Go | `OPENCODE_API_KEY` | `opencode-go` |
44
45
  | Amazon Bedrock | `AWS_BEARER_TOKEN_BEDROCK` | `amazon-bedrock` |
45
46
  | Radius | `RADIUS_API_KEY` | `radius` |
47
+ | TypeSafe (classifier models only) | `TYPESAFE_API_KEY` | `typesafe` |
46
48
  | Hugging Face | `HF_TOKEN` | `huggingface` |
47
49
  | Fireworks | `FIREWORKS_API_KEY` | `fireworks` |
48
50
  | Together AI | `TOGETHER_API_KEY` | `together` |
@@ -56,6 +58,8 @@ Since Pi 0.80.8, built-in catalogs are complemented by dynamic ones: `/model` re
56
58
  | Xiaomi MiMo | `XIAOMI_API_KEY` | `xiaomi` |
57
59
  | Xiaomi MiMo Token Plan (CN/AMS/SGP) | `XIAOMI_TOKEN_PLAN_{CN,AMS,SGP}_API_KEY` | `xiaomi-token-plan-{cn,ams,sgp}` |
58
60
 
61
+ With no Anthropic key or token set, Pi 0.99.2+ uses Anthropic workload identity federation when `ANTHROPIC_FEDERATION_RULE_ID`, `ANTHROPIC_ORGANIZATION_ID`, and `ANTHROPIC_IDENTITY_TOKEN_FILE` are set (plus optional `ANTHROPIC_SERVICE_ACCOUNT_ID` and `ANTHROPIC_WORKSPACE_ID`). The Anthropic SDK exchanges and refreshes the token, re-reading the identity token file, so keep that file fresh for long sessions.
62
+
59
63
  ## Auth File
60
64
 
61
65
  `~/.pi/agent/auth.json` stores API keys and OAuth tokens with `0600` permissions:
@@ -86,12 +90,14 @@ Entries may include an `env` object (Pi 0.79.5) for provider-scoped environment
86
90
 
87
91
  ## Credential Resolution Order
88
92
 
89
- 1. `--api-key`
93
+ 1. `--api-key` (runtime override)
90
94
  2. `auth.json`
91
- 3. Environment variables
92
- 4. Custom provider keys from `models.json`
95
+ 3. `apiKey` from `models.json`
96
+ 4. Environment variables or ambient cloud credentials
97
+
98
+ Provider extensions can define their own authentication behavior.
93
99
 
94
- ## Cloud Providers
100
+ ## Provider-Specific Setup
95
101
 
96
102
  ### Azure OpenAI
97
103
 
@@ -151,12 +157,11 @@ Use `models.json` for OpenAI-compatible, Anthropic-compatible, Google-compatible
151
157
 
152
158
  ```json
153
159
  {
154
- "providers": [
155
- {
156
- "name": "ollama",
160
+ "providers": {
161
+ "ollama": {
157
162
  "baseUrl": "http://localhost:11434/v1",
158
163
  "api": "openai-completions",
159
- "apiKey": "unused",
164
+ "apiKey": "ollama",
160
165
  "models": [
161
166
  {
162
167
  "id": "llama3.2",
@@ -175,10 +180,12 @@ Use `models.json` for OpenAI-compatible, Anthropic-compatible, Google-compatible
175
180
  }
176
181
  ]
177
182
  }
178
- ]
183
+ }
179
184
  }
180
185
  ```
181
186
 
187
+ `providers` is an object keyed by provider ID, not an array; the optional `name` field is only a display label. A `models` entry adds or replaces a model with the same ID on that provider; use `modelOverrides` to change metadata of an existing built-in or extension model.
188
+
182
189
  `apiKey` is optional (Pi 0.80.0): omit it when auth comes from `/login`, `auth.json`, or CLI `--api-key`. Like `auth.json` keys, `apiKey` and `headers` values support `!command` execution and `$ENV_VAR` interpolation; plain uppercase strings are literals. Keyless local servers (e.g. Ollama) should keep a dummy value so their models appear in `/model`.
183
190
 
184
191
  Common `api` values:
@@ -206,7 +213,7 @@ Common `api` values:
206
213
  - `compat.thinkingFormat` supports OpenAI-compatible reasoning variants: `openrouter` sends `reasoning: { effort }`, `together` sends `reasoning: { enabled }` plus `reasoning_effort` when supported, `qwen-chat-template` targets local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking`, `chat-template` (Pi 0.79.9) sends configurable `chat_template_kwargs` via `compat.chatTemplateKwargs` — e.g. `{ "thinking": { "$var": "thinking.enabled" } }` for DeepSeek models behind vLLM/Hugging Face chat templates — and `baseten` (Pi 0.84.0) sends `chat_template_args` via `compat.chatTemplateArgs`.
207
214
  - **Breaking (Pi 0.80.7):** `compat.sendSessionIdHeader` was removed. Session affinity is now controlled by `compat.sessionAffinityFormat` (`"openai"` sends `session_id`/`x-client-request-id`, `"openai-nosession"` omits the underscore-containing `session_id` header, `"openrouter"` sends `x-session-id`; default auto-detected). Replace `sendSessionIdHeader: false` with `sessionAffinityFormat: "openai-nosession"`. `sendSessionAffinityHeaders` still gates the behavior for `openai-completions`.
208
215
  - `compat.deferredToolsMode: "kimi"` (Pi 0.80.9) enables Kimi's deferred tool serialization; `compat.supportsToolReferences` / `compat.supportsToolSearch` enable native dynamic tool loading on verified custom endpoints, and `compat.supportsStrictTools` / `compat.supportsOpenAIGrammarTools` / per-model `constrainedSampling` gate constrained sampling (see `references/extensions.md`). `compat.supportsFinishReason: false` (Pi 0.84.0) tells pi to infer stop/toolUse for OpenAI-compatible streams that omit `finish_reason`.
209
- - Advanced `compat` flags exist for proxy quirks (`cacheControlFormat` — now also covering tool-result text content, `supportsReasoningEffort`, `supportsLongCacheRetention`, `supportsEagerToolInputStreaming`, `supportsStrictMode`). See [Pi `docs/models.md`](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/models.md) when a proxy rejects pi's defaults.
216
+ - Advanced `compat` flags exist for proxy quirks (`cacheControlFormat` — now also covering tool-result text content, `supportsReasoningEffort`, `supportsLongCacheRetention`, `supportsEagerToolInputStreaming`, `supportsStrictMode`). See [Pi `docs/models.md`](https://github.com/earendil-works/pi/blob/v1.0.0/packages/coding-agent/docs/models.md) when a proxy rejects pi's defaults.
210
217
 
211
218
  ### Context Overflow Recovery
212
219
 
@@ -214,12 +221,96 @@ Pi can auto-compact and retry when a provider fails with a recognized context-wi
214
221
 
215
222
  ### llama.cpp
216
223
 
217
- Pi supports the llama.cpp router server (Pi 0.81.0): configure with `/login llama.cpp`, search/download Hugging Face models and load/unload with live progress via `/llama`, then select loaded models in `/model`. The model catalog persists across restarts (fixed in Pi 0.82.1). See [Pi `docs/llama-cpp.md`](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/llama-cpp.md).
224
+ Pi supports the llama.cpp router server (Pi 0.81.0): configure with `/login llama.cpp`, search/download Hugging Face models and load/unload with live progress via `/llama`, then select loaded models in `/model`. The model catalog persists across restarts (fixed in Pi 0.82.1). Every llama.cpp chat model is also listed as a classifier model (see [Classifier Models](#classifier-models)). `"extensions": ["-builtin:llama.cpp"]` removes the provider and `/llama`. See [Pi `docs/llama-cpp.md`](https://github.com/earendil-works/pi/blob/v1.0.0/packages/coding-agent/docs/llama-cpp.md).
225
+
226
+ ## Model Types
227
+
228
+ Since Pi 0.99.0 catalogs hold typed models: `type` is `"chat"` (the default when omitted), `"image"`, or `"classifier"`. Chat-facing reads (`getModels()`, `getAvailable()`, `ctx.modelRegistry.getAll()`, `/model`) stay chat-only. `ModelRuntime`, `ctx.modelRegistry`, and pi-ai `Models` all have `getModelsOfType(type, provider?)`, `getModelOfType(type, provider, id)`, and async `getAvailableOfType(type, provider?)`. Only `ModelRuntime` and `Models` have `getAllModels()` and `getAllAvailable()` for every type; `ctx.modelRegistry` adds `findOfType(type, provider, id)` instead. pi-ai exports `isModelType()` and `getModelType()` for mixed lists. One upstream ID may have separate chat and image entries. Sources: [models.md](https://github.com/earendil-works/pi/blob/v1.0.0/packages/coding-agent/docs/models.md#use-classifier-models), [pi-ai types](https://github.com/earendil-works/pi/blob/v1.0.0/packages/ai/src/types.ts).
229
+
230
+ ### Classifier Models
231
+
232
+ Classifiers do not chat. They answer typed questions about JSON state with probabilities, and do not appear in `/model`.
233
+
234
+ | Provider | Model IDs | Auth |
235
+ |----------|-----------|------|
236
+ | `typesafe` | `jev-latest` | `TYPESAFE_API_KEY` |
237
+ | `openrouter` | `typesafe/jev-1.13`, `~typesafe/jev-latest` | `OPENROUTER_API_KEY` or `/login` |
238
+ | `cloudflare-workers-ai` | `typesafe/jev` | `CLOUDFLARE_API_KEY` + `CLOUDFLARE_ACCOUNT_ID` |
239
+ | `vercel-ai-gateway` | `typesafe-ai/jev` | `AI_GATEWAY_API_KEY` |
240
+ | `opencode` | `jev-1.13`, `jev-1.13-free` | `OPENCODE_API_KEY` |
241
+ | llama.cpp router | every loaded chat model ID | `/login llama.cpp` |
242
+
243
+ ```typescript
244
+ const jev = ctx.modelRegistry.findOfType("classifier", "typesafe", "jev-latest");
245
+ if (jev) {
246
+ const result = await ctx.modelRegistry.classify(
247
+ jev,
248
+ {
249
+ state: { message: "The change works, thanks." },
250
+ questions: {
251
+ approved: {
252
+ type: "bool",
253
+ instructions: "Does the user approve of the result?",
254
+ criteria: { true: "Approval", false: "No approval" },
255
+ },
256
+ },
257
+ },
258
+ { signal: ctx.signal },
259
+ );
260
+ const answer = result.stopReason === "stop" ? result.answers.approved : undefined;
261
+ if (answer?.type === "bool") console.log(answer.probability);
262
+ }
263
+ ```
264
+
265
+ - Question types: `choice` (`criteria` maps labels to descriptions; answer has `choice`, `probabilities`, `confidence`), `bool` (`criteria: { true, false }`; answer has `probability`), and `score` (`criteria` is an ordered list; answer has `score`, `confidence`). Pi translates `bool` to TypeSafe's wire format.
266
+ - `classify()` never rejects. Failures and aborts return `stopReason: "error"` or `"aborted"` with `errorMessage`. Check `stopReason` and each answer's `type` before use, and validate probabilities yourself if a decision depends on them (for example finite and within `[0, 1]`).
267
+ - `result.usage` carries token counts and catalog cost when the service reports them; TypeSafe's direct `jev-latest` has no catalog price.
268
+ - `jev-latest` moves with the service. Pin a versioned ID such as `openrouter`'s `typesafe/jev-1.13` when thresholds were tuned against one model.
269
+ - Codemode scripts reach classifiers through `models.classify()` (see `references/codemode.md`). The SDK equivalent is `modelRuntime.classify()`. A llama.cpp classifier accepts a per-request `temperature` to soften overconfident label probabilities.
270
+
271
+ A gate that fails closed on classifier errors is in `examples/classifier-gate.ts`.
272
+
273
+ ### Image Generation
274
+
275
+ `ModelRuntime.generateImages(model, { input }, options?)` (Pi 0.99.0) generates images with runtime-resolved auth; list image models with `getModelsOfType("image")`. OpenRouter image models such as `google/gemini-2.5-flash-image` appear under the `openrouter` provider with its credential. Like `classify()`, it returns an error result rather than rejecting. Image models do not appear in `/model`: extensions call `ctx.modelRegistry.generateImages()` and codemode scripts call `models.generateImages()` (both Pi 1.0.0). Codemode adds a script's image usage to its result; an extension tool must report the returned `usage` on its own tool result, combined across calls. `input` takes text blocks plus `{ type: "image", data, mimeType }` blocks to edit or use as references; output images are base64 blocks and are not saved to disk.
276
+
277
+ In pi-ai, image models live in the same catalog as chat models: `builtinModels()` includes them, `models.getModelOfType("image", provider, id)` finds one (type `ImageModel`), `models.generateImages()` runs it, and `createProvider({ models, images })` registers image implementations. The separate `*Images*` API was removed in pi-ai 0.99.0.
278
+
279
+ ## Virtual Models
280
+
281
+ A virtual model (experimental, Pi 0.99.0) is a selectable entry that routes each request to a physical model. It is for routing by task, cost, or conversation state, not for permission decisions. Source: [virtual-models.md](https://github.com/earendil-works/pi/blob/v1.0.0/packages/coding-agent/docs/virtual-models.md), [`jev-router.ts`](https://github.com/earendil-works/pi/blob/v1.0.0/packages/coding-agent/examples/extensions/jev-router.ts).
282
+
283
+ ```typescript
284
+ pi.registerVirtualModel({
285
+ provider: "router",
286
+ id: "auto",
287
+ name: "Auto",
288
+ thinkingLevels: ["low", "high"],
289
+ route(request, ctx) {
290
+ const sticky = request.failed ?? request.previous;
291
+ if (request.reason !== "user" && sticky) {
292
+ return { model: sticky.model, thinkingLevel: sticky.thinkingLevel ?? "medium" };
293
+ }
294
+ const id = request.thinkingLevel === "high" ? "claude-sonnet-4-5" : "claude-haiku-4-5";
295
+ const model = ctx.modelRegistry.find("anthropic", id);
296
+ if (!model) throw new Error(`anthropic/${id} is not in the catalog`);
297
+ return { model, thinkingLevel: "medium" };
298
+ },
299
+ });
300
+ ```
301
+
302
+ - `route()` runs before every request with `reason` (`user`, `continuation`, `retry`, `direct`), `previous`, `failed`, `state`, `messages`, and `signal`. Returning `previous` for continuations and `failed` for retries keeps prompt caches and thinking signatures valid.
303
+ - Returned `state` (JSON) is stored on the session branch and comes back as `request.state`; `direct` requests (compaction summaries, `ctx.modelRegistry.streamSimple()`) have none.
304
+ - The route must be a physical model with credentials; throwing or returning a virtual model ends the request with an error.
305
+ - Selection (`ctx.model`, `model_change`) names the virtual model; each assistant message records the physical `provider`/`model`/`thinkingLevel`. The footer shows both; `/session` lists cost per physical model.
306
+ - `pi.unregisterVirtualModel(provider, id)` removes one (`unregisterProvider()` does not). SDK code can call `modelRuntime.registerVirtualModel(definition)`.
218
307
 
219
308
  ## Custom Providers via Extensions
220
309
 
221
310
  Use extensions when you need custom streaming, custom headers, OAuth/device-flow logic, or want to override an existing provider's `baseUrl`/`headers`:
222
311
 
312
+ A config-form `models` list replaces the provider's models across chat, image, and classifier operations; image and classifier entries need an explicit `type` and implementations keyed by `api` through `images` and `classifiers`. Registering only `baseUrl` or `headers` keeps the built-in models. See [custom-provider.md](https://github.com/earendil-works/pi/blob/v1.0.0/packages/coding-agent/docs/custom-provider.md).
313
+
223
314
  ```typescript
224
315
  // Full registration
225
316
  pi.registerProvider("my-provider", {