Add a model provider
Route a new provider's models through Switchboard, so a model: directive and agents.<name>.model can name them.
You need:
- Write access to the installation's
config.yamland the environment the bot runs in. - The provider's API base URL and an API key. Keys are read from environment variables only, never from config files.
Add the block
Anything that speaks the OpenAI /chat/completions shape needs no code:
providers:
groq:
wire: openai-chat
baseUrl: https://api.groq.com/openai/v1
apiKeyEnv: GROQ_API_KEYOpenAI itself is the same shape, as the example configuration's openai: block shows — with the same limits that block's comment names (Chat Completions only, no reasoning control, tokens metered but not priced). An aggregator is the same shape too: the example config carries a commented OpenRouter block, and Configure your defaults says what the compatible adapter does not do for it. A local server is the same shape; Ollama, for example:
providers:
local:
wire: openai-chat
baseUrl: http://localhost:11434/v1wire is the new spelling for the shape — anthropic-messages, openai-chat or openai-responses. The old type words (anthropic, openai-compatible) still load as their wire for one release, and a block may declare either, never both.
What else a block declares
A block carries what a run needs to know about its models (record 0052):
| Key | Means |
|---|---|
wire | the shape the block speaks — anthropic-messages, openai-chat or openai-responses |
vendor | the vendor whose models the block serves: a name, or model when the block is an aggregator and each model id names its vendor first (openrouter/anthropic/claude-sonnet-4) |
catalog | the pi registry file this block's cards are read from — default, the block's own name when the library ships such a file; none for a local server with no catalog |
models.<id> | the operator's per-model override: catalogModel (exact catalog id for metadata only), levels (our effort words → the wire's word, or null to refuse one), capField, window, inputs, cache, price |
passthrough | extra body fields merged into every request (a vendor-only feature), never a control |
Nothing is required beyond wire (or its type alias) and, for a keyed provider, apiKeyEnv; the registry card and the wire's own defaults fill the rest, and a field no layer names goes out unvouched with a note on the run's record saying so. Set models.<id> where you know better than the registry.
Keep wire aliases separate from catalog identities
On the public OpenRouter API, use the exact vendor-prefixed model id: openrouter/openai/gpt-5.3-codex, for example. A bare short name that uniquely matches a catalog model is refused before workspace setup with the exact ref. The request is never rewritten. Explicit operator aliases keep their declared identity; custom gateways and unknown or ambiguous catalog names keep their existing fallback behavior.
For a catalog Chat Completions card whose endpoint matches the selected public OpenRouter API, the gateway contract vouches max_completion_tokens. OpenRouter translates this field to each upstream's spelling. This does not vouch a custom gateway or unknown card, and explicit operator or registry cap fields still win. See the OpenRouter API contract.
A display name or gateway alias may differ from the catalog's exact id. No alias is inferred: without an exact match the card's unknown fields remain unvouched. After independently verifying which catalog model an alias actually serves, an operator may bind that metadata without changing the outbound model id:
providers:
gateway:
wire: openai-chat
vendor: model
catalog: openrouter
models:
display-alias:
catalogModel: vendor/catalog-modelThe model ref remains gateway/display-alias on the wire. catalogModel selects the pinned registry's window, inputs, levels and prices and the exact endpoint id for providers check; it does not declare the alias's vendor, a cache rule or a cap field absent from that catalog card. A missing mapped card still falls back honestly. Pin capField or cache separately under the wire alias, only from verified endpoint/operator facts. A supported cap spelling still needs an explicit pin when it was merely a wire default. Cache-read pricing alone does not prove a cache-control rule; do not turn an unknown rule into automatic just to remove a notice. The check reports facts, never mutates configuration; enabling a mapping is a separate operator change.
Set the key and restart
Put GROQ_API_KEY in the bot's environment (.env locally) and restart. A missing key fails fast, by name.
Use it
The provider's models are now valid as groq/<model-id> anywhere a model is accepted:
- a
model:directive config set- an
agents.<name>.modelentry (Configure your defaults)
Check the block against the provider
The last step of adding a block: run providers check (chat, CLI — npm run cli -- providers check — HTTP or MCP). For each aggregator model the configuration names it reads the provider's own endpoints listing and prints where the resolved card disagrees — supported parameters, context length, modalities — with the models.<id> override that would pin each fact (model-proxy.md item 12). Pin what it reports before leaning on the model: a fact no layer vouches for goes out unvouched with a note on the run's record. When a pi bump drops a card the example config still names, npm run check:registry-drift (part of check:consistency) fails by name.
Any other wire shape
There is no adapter to write: Switchboard holds no model adapters of its own. A run's model calls are made by pi inside the run's container and go through the bot's model proxy, which forwards exactly two wire shapes — Anthropic Messages (POST /v1/messages) and OpenAI Chat Completions (POST /v1/chat/completions) — with the deployment's key (model-proxy.md); the one door's, thread-reply gate's and memory reflection's calls go through pi's model library (@earendil-works/pi-ai, src/core/harness/piAi.ts), which reads the same two wires — anthropic-messages and openai-chat — off the same block (harness-pi.md item 13). A block declaring wire: openai-responses is a declaration this release: the proxy serves that route in a later unit, and until then a run on it meets the provider's own answer. A new block of any declared wire needs nothing in code. A vendor that speaks none of the three needs a new wire: its API named in piApiFor (src/core/harness/piAi.ts) for the door, thread-reply gate and reflection, the shape piModelsJson writes for a run's pi (src/core/harness/pi/process.ts), the proxy's forward and meter for it (src/channels/modelProxy.ts, src/core/modelProxy/usage.ts), the card's cap field and cache rule (src/core/modelCard.ts), and the validator that admits the wire (src/config/validate.ts) — with tests beside each.
Next
- Seams with two implementations: why the boundary is an interface.
- How a request flows: where the provider sits in the pipeline.
- Add an agent: the other seam you extend without touching the dispatcher.