Extending Soma
Skills, extensions, events, APIs — build on top of Soma.
Built on Pi — inherits full extension system. Skills: markdown instructions in .soma/skills/ or ~/.soma/agent/skills/. Extensions: TypeScript hooks into agent lifecycle (before_agent_start, tool_result, session_shutdown). Built-in extensions: soma-boot (identity + protocols + muscles), soma-breathe (breath cycle + session rotation), soma-guard (safe file operations), soma-header (branded σῶμα header), soma-hub (community hub), soma-route (inter-extension communication), soma-scratch (scratch pad), soma-statusline (context/cost/git footer).
Soma is built on Pi and inherits its full extension system. You can add skills, extensions, and custom tools.
Skills
Skills are specialized instructions that load when a task matches their description. They’re framework-agnostic — a skill from Claude Code, Cursor, or any agent system works in Soma without modification.
What makes Soma different: muscles and protocols refine skills over time. A logo design skill teaches the technique. A muscle learns your specific preferences. A protocol enforces your brand standards. The skill provides raw expertise; Soma’s behavioral layers personalize and improve it through repeated use — without you ever asking.
Installing Skills
Install from the hub or place manually:
/hub install skill my-skill # from Soma Hub
Or place skill directories in one of these locations:
| Location | Scope |
|---|---|
.soma/skills/ | Project-local (only loads in this project) |
~/.soma/agent/skills/ | Global (loads for all projects) |
Creating Skills
Create a directory with a SKILL.md file:
my-skill/
└── SKILL.md
SKILL.md contains:
- A
descriptionthat tells Soma when to load it - Instructions for how to handle the task
- Optional file references for additional context
# My Custom Skill
**Description:** Help with deploying to production servers.
## Instructions
When the user asks about deployment:
1. Check the deployment config at `deploy.yaml`
2. Verify all tests pass
3. ...
Place in .soma/skills/ (project) or ~/.soma/agent/skills/ (global).
Extensions
Extensions are TypeScript files that hook into Soma’s lifecycle events.
Extension Locations
| Location | Scope | Walk-up |
|---|---|---|
<cwd>/.soma/extensions/ | Project-local — loads only when CWD is in this project | No (cwd only) |
~/.soma/extensions/ | User-global — loads for all your Soma sessions | No |
~/.soma/agent/extensions/ | Runtime-bundled — ships with Soma; do not write here | No |
Where to put YOUR extensions: ~/.soma/extensions/ for personal/global, or <project>/.soma/extensions/ for project-scoped. The ~/.soma/agent/extensions/ location is the runtime install — writes there are protected by soma-guard and won’t survive Soma updates.
Opt-out: --no-extensions (or -ne) skips extension discovery for one session. Explicit --extension <path> always works.
Extension Security
Extensions run with full system permissions. Configure an allowlist in settings.json to get warnings about unrecognized extensions:
{
"extensions": {
"allowlist": ["soma-boot.ts", "soma-breathe.ts", "..."]
}
}
See Configuration → Extension Security for details.
Writing an Extension
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function myExtension(pi: ExtensionAPI) {
// Register a command
pi.registerCommand("hello", {
description: "Say hello",
handler: async (_args, ctx) => {
ctx.ui.notify("Hello from my extension!", "info");
},
});
// Hook into session lifecycle
pi.on("session_start", async (_event, ctx) => {
// Do something on session start
});
pi.on("turn_end", async (_event, ctx) => {
// Do something after each agent turn
});
}
Available Events
| Event | When |
|---|---|
session_start | Session loads (check event.reason: startup, reload, new, resume, fork) |
turn_start | Agent begins processing |
turn_end | Agent finishes processing |
message_end | Message fully rendered |
tool_result | Tool call completes |
before_agent_start | Before each agent turn — can modify the system prompt by returning { systemPrompt } |
session_shutdown | Session closing |
Hot-reload: Pi’s /reload command re-loads extensions via jiti.import
(mtime-keyed cache). Extension file edits are picked up without restarting
the process. core/*.ts changes may hot-reload through the chain; dist/
changes require a full restart.
Cache-safe registration vs hot-reload (don’t conflate)
Four separate concerns. Each lever covers a different layer.
| Concern | What it means | Layer |
|---|---|---|
| Cache-safe registration | Capability is added without busting Anthropic’s prompt cache (~$1-2/session saved). Lives in soma-route’s runtime registry, not in the system prompt. | Anthropic API |
Module hot-reload (/reload) | Edits to an extension’s .ts file take effect after /reload. Pi re-imports every extension via fresh jiti (moduleCache: false), so source changes are picked up. | Node module cache |
Prompt rebuild (/rebuild) | The compiled system prompt is restored from disk cache on /reload. To regenerate it from current body/*.md and current tool definitions, you need /rebuild. | System prompt |
| Subprocess pickup (zero ceremony) | Tool wrappers that exec/spawn an external script pick up edits to the script on the next invocation. The wrapper is JS-in-memory; the script is a fresh subprocess each call. | OS process |
Authority for each, verified:
agent-session.js:1894—session.reload()shutdown→buildRuntime→session_startextensions/loader.js:271—loadExtensionModulecreates fresh jiti per reload (moduleCache: false)soma-boot.jscache-stickiness — system prompt restored from disk on reload (avoids cache write)- Pi changelog 0.56.0 (March 2026, #1720) — “Runtime tool registration now applies immediately in active sessions. Tools registered via
pi.registerTool()after startup are available topi.getAllTools()and the LLM without/reload.” extensions/_shared/meta-tool-factory.ts:96-130— addons auto-discovered viareaddirSync+ dynamicimport()onsession_startextensions/soma-addons/code.ts:38— canonical “thin wrapper around CLI” pattern (runCode()shells out viaexecSync)
Decision matrix for what to run after editing:
| What you edited | Cache impact | What to run |
|---|---|---|
New pi.registerTool({name: "foo", ...}) added at runtime (any time after session start) | Busts cache on next compile (def goes into system prompt) | Nothing immediate — Pi 0.56.0+ makes the tool live without /reload. The system prompt stays stale until /rebuild (so the model sees your new tool’s description on next rebuild, not now). |
pi.registerTool execute body of an existing tool (e.g. komodo.ts) | None (definition unchanged in prompt) | /reload — the executor function lives in JS memory and won’t change without re-import. |
pi.registerTool description / parameters / promptSnippet of an existing tool | Busts cache on next compile | /reload (updates registry) + /rebuild (so the model actually sees the new fields). Without /rebuild, registry has new fields but prompt instructs the model to use old ones — tool calls fail with parameter-validation errors. |
New route.provide("soma:foo.bar", impl) cap (e.g. dropping a new file in soma-addons/) | Cache-safe — caps live in soma-route’s registry, not in prompt | /reload — the meta-tool’s session_start handler scans *-addons/ and dynamic-imports new files. (No file watcher; rescan only fires on session_start.) |
route.provide cap impl body | Cache-safe — caps not in prompt | /reload — same Node-memory rule as pi.registerTool execute. |
CLI script behind a wrapper (e.g. repos/agent/scripts/soma-code.sh while soma-addons/code.ts shells out to it via execSync) | None | Nothing. Each cap invocation spawns a fresh subprocess; the OS reads the script at exec time. Edit the script, save, next call uses the new code. |
amps/scripts/* drop-in (e.g. soma <name> script discovery) | None | Nothing for the script itself. /reload only if you also edited soma-boot.ts’s discovery logic. |
body/*.md (identity, soul, voice, mind template) | Busts cache on next compile | /rebuild if you want it live now; otherwise skip till next session. |
Why prompt-visible-field edits need /rebuild too: the system prompt is
compiled at session start and cached to disk (state/<id>). /reload
rebuilds the extension runner and tool registry, but soma-boot’s
before_agent_start handler restores the cached prompt to avoid a per-reload
cache write. Result: the LOCAL tool registry has the new fields but the
SYSTEM PROMPT sent to Anthropic still has the old ones. Renaming a parameter
mid-session without /rebuild is the classic break.
Why route.provide is exempt: cap names + descriptions live in
soma-route’s runtime registry. The system prompt only describes the meta-tool
(soma, dev, etc.) — a stable description that doesn’t change when
individual caps do. Cap details are returned at runtime by op:'list' /
op:'help' invocations, fetched fresh from the registry each call.
Why CLI scripts behind wrappers need nothing: the wrapper (*.ts) does
execSync("soma code ...") or spawn("bash", [scriptPath, ...]). The
subprocess loads the script’s bytes from disk at exec time — not from any
Node cache. Edit, save, next invocation runs the new code. This is the
“tooling tied to CLI” pattern Curtis verified previously: e.g. editing
scripts/_dev/soma-audit-tickets.sh doesn’t need /reload because
dev-addons/audit.ts only spawns it; the bash script itself isn’t in JS
memory.
Don’t conflate the four concepts: “cache-safe” doesn’t mean “hot-reloads
automatically.” /reload doesn’t mean “the model sees your new tool
description.” Pi 0.56.0’s runtime registration doesn’t mean “editing a tool’s
code takes effect without /reload” — it means “adding a new tool at runtime
doesn’t need /reload.”
Modifying the System Prompt
The before_agent_start event is the hook for prompt modification. Return
a { systemPrompt } object to replace or augment what Pi compiled:
pi.on("before_agent_start", async (event, _ctx) => {
// event.systemPrompt is Pi's compiled prompt (may include customPrompt
// from .soma/SYSTEM.md + context files + skills XML + date/cwd)
const modified = event.systemPrompt + "\n\n<!-- my addition -->";
return { systemPrompt: modified };
});
Pi resets the system prompt to base each turn, so the handler must return the full prompt every time — not just the first turn.
Soma’s soma-boot.ts owns the main path (template-driven compile via
_mind.md → full prompt). New extensions should generally augment
rather than replace, or use Soma’s existing compile hooks instead of
fighting them.
Available APIs
| API | What it does |
|---|---|
pi.registerCommand(name, opts) | Add a /command |
pi.registerTool(def) | Register a tool (raw — prefer somaRegisterTool for Soma tools) |
pi.sendUserMessage(text, opts) | Inject a message |
pi.appendEntry(type, data) | Persist state in session |
pi.on(event, handler) | Listen to lifecycle events |
pi.getActiveTools() | Tool names enabled in this session |
pi.getAllTools() | All registered tools (info only — strips promptSnippet) |
pi.getThinkingLevel() | Current thinking level |
ctx.ui.notify(msg, level) | Show notification |
ctx.ui.setHeader(factory) | Custom header component |
ctx.ui.setFooter(factory) | Custom footer component |
ctx.getContextUsage() | Token usage stats |
ctx.newSession(opts) | Create new session |
See the Pi extension docs for the full API reference.
Soma Tools
Soma tools are capabilities the agent can call. They’re registered through
Pi’s tool API but wrapped by Soma’s somaRegisterTool() so _tools.md
configuration applies and the full guidance (promptSnippet + per-tool
promptGuidelines) reaches the system prompt.
Write tools with somaRegisterTool — not pi.registerTool — whenever
the tool should be user-configurable or should benefit from the rich
prompt rendering.
Writing a Soma tool
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import { somaRegisterTool } from "../core/tool-registry.js";
export default function myExt(pi: ExtensionAPI) {
somaRegisterTool(pi, {
name: "my_tool",
label: "My Tool",
description: "Long-form explanation the model reads.",
promptSnippet: "my_tool: one-line tagline for the 'Available tools' header",
promptGuidelines: [
"When X, prefer my_tool over bash",
"Chain my_tool → read for full context",
],
parameters: Type.Object({
input: Type.String({ description: "What to process" }),
}),
executionMode: "parallel",
execute: async (_id, { input }, _signal, _onUpdate, _ctx) => {
return {
content: [{ type: "text" as const, text: `Processed: ${input}` }],
details: undefined,
};
},
});
}
Fields:
name— identifier the model invokesdescription— full explanation (used as fallback in prompt)promptSnippet— snappy tagline (primary prompt render)promptGuidelines— per-tool mechanics bullets prefixed[tool_name]in the promptparameters— TypeBox schema, validated by Pi beforeexecuterunsexecutionMode—"parallel"for read-only (safe concurrent) or"sequential"for side-effectsexecute—(toolCallId, params, signal, onUpdate, ctx) => Promise<AgentToolResult>
What somaRegisterTool does
- Reads merged
_tools.mdconfig from the soma body chain. - If the tool’s name is in
## Disabledand not hardwired — skips Pi registration entirely. - Merges any
## Overridesblock onto the definition (description,promptSnippet,promptGuidelines,executionMode). - Stores the effective definition in Soma’s prompt registry (for
buildToolSection). - Forwards to
pi.registerTool()so the tool is invocable.
See Tools for the full _tools.md format and the bundled
Soma tool set.
pi.registerTool vs somaRegisterTool
pi.registerTool | somaRegisterTool | |
|---|---|---|
| Tool is invocable | ✓ | ✓ |
Respects _tools.md disable | ✗ | ✓ |
Applies _tools.md overrides | ✗ | ✓ |
promptSnippet reaches system prompt | ✗ (Pi’s ToolInfo strips it) | ✓ |
promptGuidelines reach system prompt | ✗ | ✓ |
Appears in /soma prompt diagnostics | degraded | full |
Use pi.registerTool directly only for experimental one-offs or when
integrating a third-party library that registers its own tools.
Soma’s Built-in Extensions
Soma ships with these extensions:
| Extension | Purpose |
|---|---|
soma-boot.ts | Identity loading, preload, /exhale, /soma commands, script discovery |
soma-breathe.ts | Breath cycle — /inhale, /breathe, /rest, session rotation, preload management |
soma-guard.ts | Safe file operation enforcement — intercepts writes to unread/critical files, blocks dangerous bash commands |
soma-header.ts | Branded σῶμα header with memory status |
soma-hub.ts | Community hub — /hub install, /hub find, /hub share, /hub fork |
soma-route.ts | Capability router — inter-extension communication via capabilities and signals |
soma-scratch.ts | Scratch pad — /scratch save, /scratch list, cross-session snippet storage |
soma-statusline.ts | Footer with model, context %, cost, git status, auth type |
soma-tools.ts | soma:* namespace meta-tool — user-facing capability surface |
These install to ~/.soma/agent/extensions/ and can be customized or replaced.
Multi-agent Workflows (soma-dev delegate)
Developers can compose child agents into named pipelines via soma-dev delegate <workflow>:
| Workflow | Pipeline | Use when |
|---|---|---|
pr | brief → changelog_curator + pr_author + doc_writer + verifier | About to open a PR; want rich CHANGELOG narrative + PR description auto-drafted |
pr-brief | soma-pr-brief.sh only (no agents, instant) | Just the structured brief for manual PR writing |
ci-fix <url> | issue_investigator → builder → verifier | A nightly test failed and filed a GitHub issue |
cycle <brief.md> | intern (investigate) → intern (build) → verifier → pr_author | Multi-step cycle that exceeds builder’s 25-call default budget |
changelog | changelog_curator | Just the rich [Unreleased] section |
doc-update | doc_writer | Recent code changes need doc sync |
audit [tickets...] | auditor | Batch verdict on kanban tickets (SHIPPED / STALE / STILL-VALID) |
Each role has a budget cap (max-tool-calls + max-cost-usd) defined in body/children/<role>.md frontmatter. The intern role has the largest budget (80 calls / $0.80) for complex investigations + builds; builder is bounded for surgical edits (25 calls / $0.50).
For a deep cycle (cycle 16’s 9-step model-aware-breathe implementation took ~80+ tool calls), use soma-dev delegate cycle <brief.md> to chain investigate → build → verify → PR-author. See scripts/_dev/soma-dev/commands/delegate.sh.
Namespaces
Soma uses three top-level meta-tools to organize capabilities. Each is a single Pi tool registration with multiple addons routed through soma-route:
| Namespace | Audience | Distribution | Caps |
|---|---|---|---|
soma:* | Every Soma install | Ships in npm tarball | soma:agent.*, soma:body.*, soma:browser.*, soma:code.*, soma:docs.*, soma:focus.*, soma:github.*, soma:new.*, soma:terminals.* |
somaverse:* | Somaverse-licensed | Proprietary, separate install | workspace ops, plugin builder, AI helpers |
dev:* | Agent contributors only | Build-excluded from npm + soma-beta | dev:hub.* (hub introspection), dev:audit.* (deps + CI) |
When to add a new cap
- Pick the namespace. If end users would benefit →
soma:*. If only people working ON the agent need it →dev:*. If it’s part of the proprietary tier →somaverse:*. - Pick the family. Group by domain (
code,docs,hub, etc.). New family = new file under the namespace’s addon dir. - Implement. Mirror an existing addon (e.g.
soma-addons/docs.ts). Each cap is an*Implasync function + aroute.provideregistration. The meta-tool factory auto-discovers files under<namespace>-addons/at session-start. - No
pi.registerToolfor new top-level tools. That cache-busts the prompt prefix (~$1-2/session). Always add as an addon under an existing meta-tool. - Test with
<namespace>(op='list')and<namespace>(op='call', cap='<namespace>:<family>.<action>', args={...}).
Project-local caps (route.provide, no factory)
The createMetaTool factory only auto-discovers addons in the global
~/.soma/agent/extensions/<namespace>-addons/ directory. For caps that
should only load when CWD is in a specific project (e.g. project-specific
tooling like the Gravicity cycle audit), register directly in a
project-local extension at <project>/.soma/extensions/<name>.ts:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function myProjectAddon(pi: ExtensionAPI) {
// Defer to session_start so __somaRoute is initialized.
pi.on("session_start", async () => {
const route = (globalThis as any).__somaRoute;
if (!route) return;
route.provide(
"soma:myfamily.action",
async (args: any = {}) => {
// Your logic. Return a string (or JSON-serializable value).
return `Processed: ${args.input ?? "(nothing)"}`;
},
{
provider: "myaddon (project-local)",
description: "What this cap does. args: {input?:string}.",
},
);
});
}
Same cache-safety as factory-discovered addons. Cap lives in soma-route’s runtime registry, not the system prompt. Zero cost.
Same invocation surface. soma(op='call', cap='soma:myfamily.action', args={...})
just works — no UI difference between project-local and global addons.
Why session_start, not module top-level: the route singleton is
initialized when soma-route’s session_start handler fires. Registering
before that is racey. The pi.on("session_start", ...) defer pattern
is the same discipline createMetaTool uses internally.
Pattern: thin addon, fat CLI. For non-trivial logic, put the body in
a CLI script (.mjs / .sh / any subprocess-runnable file) and shell
out from the cap via execSync. Edits to the script are picked up next
invocation — no /reload needed. Reference: soma-addons/code.ts shells
out to soma code CLI; arzadon-fitness/.soma/extensions/meta-addon.ts
shells out to meta.mjs.
When to use this vs. global factory addon:
| Concern | Project-local route.provide | Global factory addon |
|---|---|---|
| Where it loads | Only when CWD is in this project | Everywhere |
| Where to put the file | <project>/.soma/extensions/*.ts | ~/.soma/agent/extensions/<ns>-addons/*.ts |
| Auto-discovered? | No — manual route.provide call | Yes — factory scans the dir |
Need register(route) export? | No — default-export an (pi) => void | Yes — factory expects it |
| Cache impact | Zero | Zero |
| Hot-reload edits | /reload for the .ts; subprocess pickup for any shelled-out script | Same |
Project-local caps are the right call when the cap is genuinely project-specific (operates on this project’s files, this project’s secrets, this project’s structure). Promote to global when a second project would benefit from the same cap.
dev:* namespace (agent-contributor only)
The dev:* namespace is for tools that audit, lint, or inspect the agent itself — things only people working on Soma need. It’s intentionally NOT shipped to end users:
extensions/dev-tools.tsregisters the meta-toolextensions/dev-addons/*.tsare the cap familiesbuild-dist.mjsbuilds them locally for dogfoodsoma-release.sh § Step 3strips them from the soma-beta copyverify-bootstrap-clean.sh § Test 5asserts the strip code is in place
Result: dev contributors can call dev:hub.audit to verify hub state; end users running npm install meetsoma never see the namespace.
For the full design + reasoning: .soma/releases/plans/active/dev-meta-tool/README.md.
soma-route.ts
Capability router for inter-extension communication. Extensions can’t import from each other — the router provides a clean API for sharing functions and broadcasting events.
Two patterns:
// Capabilities — one provider, many consumers (service registry)
const route = (globalThis as any).__somaRoute;
route.provide("my:capability", myFunction, { provider: "my-ext" });
// In another extension:
const fn = route?.get("my:capability");
if (fn) await fn();
// Signals — many emitters, many listeners (pub/sub)
route.on("my:event", (data) => { /* react */ });
route.emit("my:event", { key: "value" });
Built-in capabilities (provided by soma-boot and soma-statusline):
| Capability | Provider | Description |
|---|---|---|
session:new | soma-boot | Start fresh session |
session:compact | soma-boot | Trigger compaction |
session:reload | soma-boot | Reload extensions |
keepalive:toggle | soma-statusline | Enable/disable cache keepalive |
keepalive:status | soma-statusline | Get keepalive state |
context:usage | soma-boot | Get token usage |
Commands: /route shows all registered capabilities and signal listeners.
Why it exists: Pi’s sendUserMessage() can’t trigger slash commands (by design). The router bridges the gap — command handlers capture capabilities (like newSession) and share them with event handlers (like turn_end) that need them for features like auto-breathe rotation.
External tool → Soma bridge (the inbox)
Soma’s router includes a drop-in inbox for external tools (CLI scripts, CI jobs, browser automation, file watchers) to inject signals into a running Soma session.
How it works:
- On
session_start,soma-route.tsgenerates an 8-char session token and writes it to.soma/inbox/.token. The inbox directory is gitignored automatically. - External tools drop JSON files into
.soma/inbox/:{ "signal": "ci:result", "token": "a1b2c3d4", "data": { "status": "passed", "tests": 45 }, "ts": "2026-04-19T10:00:00Z", "source": "github-actions" } - On every
turn_end,soma-route.tsreads the inbox, verifies each file’s token + signal allowlist, emits valid signals via the router, and deletes the consumed file. - Any extension listening via
route.on(signal, handler)receives the payload.
Allowed signals (hard allowlist — not configurable, enforced in
soma-route.ts:INBOX_ALLOWED_SIGNALS):
| Signal | Intended use |
|---|---|
studio:vote | Design-review votes from a studio tool |
studio:feedback | Design-review comments |
ci:result | CI build/test outcomes |
deploy:status | Deployment status updates |
fs:changed | File watcher notifications |
browser:capture | Browser automation screenshots |
scheduled:task | Cron/scheduled job output |
external:notify | Generic custom notification |
Internal signals (session management, guard, breathe) are never injectable from outside — hard boundary.
Commands: /route inbox shows the current token, pending message
count, and allowed signals list.
Use case: let a VS Code extension, browser tool, or CI job poke a running Soma without opening a socket or proxy.
soma-guard.ts
Graduated from the safe-file-ops muscle — the muscle teaches the pattern, this extension enforces it.
What it guards:
- Write to unread file — confirms before overwriting files the agent hasn’t read this session
- Critical paths — always confirms writes to identity files, settings, protocols,
.env - Dangerous bash — confirms
rm -rf, force push,git reset --hard, etc. - Safe paths exempt — preloads, session logs, review directories skip guards
Commands: /guard-status shows reads tracked, dirs listed, and intervention count.
Custom Model Providers
Extensions can register custom model providers using pi.registerProvider(). This enables corporate proxies, self-hosted models, OAuth flows, and custom APIs.
Override an Existing Provider
Route requests through a proxy without losing the provider’s model list:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
pi.registerProvider("anthropic", {
baseUrl: "https://proxy.corp.com/anthropic",
headers: { "X-Corp-Auth": "CORP_TOKEN" }
});
}
Register a New Provider
Add a provider with custom models:
export default function (pi: ExtensionAPI) {
pi.registerProvider("my-llm", {
baseUrl: "https://api.my-llm.com/v1",
apiKey: "MY_LLM_API_KEY",
api: "openai-completions",
models: [
{
id: "my-model-large",
name: "My Model Large",
reasoning: true,
input: ["text", "image"],
cost: { input: 3.0, output: 15.0, cacheRead: 0.3, cacheWrite: 3.75 },
contextWindow: 200000,
maxTokens: 16384
}
]
});
}
Supported APIs
| API | Use For |
|---|---|
openai-completions | Most OpenAI-compatible servers |
openai-responses | OpenAI Responses API |
anthropic-messages | Anthropic or compatible proxies |
google-generative-ai | Google Generative AI |
Unregister
pi.unregisterProvider("my-llm");
Key Resolution
apiKey and headers values support:
- Literal:
"sk-..."— used directly - Env var:
"MY_API_KEY"— reads the named variable - Shell command:
"!op read 'op://vault/key'"— executes and uses stdout
For a simpler approach without writing an extension, see Custom Providers in models.json.