Extension catalogs
Register tools, agent profiles, sub-agents, providers, MCP servers and skills, and edit any catalog item, built-ins included, with replayable transforms.
A catalog is a list the app builds the agent from: tools, agent profiles and sub-agents, models, providers, MCP servers and skills. An extension adds items with register and edits the whole list with transform. ctx.plugins loads Agent Plugins into the skills and MCP catalogs, and ctx.instructions adds text to the system prompt.
Use register for something the extension brings. Use transform to change what is already there: rename a model, hide a tool, give an MCP server a longer timeout, change the default profile.
Register#
Each register returns a Disposable. Disposing it removes the item. Unloading the extension removes them all.
ctx.tools.register('kitchen_count', {
spec: {
name: 'kitchen_count',
description: 'Read the counter, or bump it by "bump".',
inputSchema: { type: 'object', properties: { bump: { type: 'integer', minimum: 1 } }, additionalProperties: false },
},
isConcurrencySafe: input => input.bump === undefined,
async execute(input) {
return JSON.stringify({ count: typeof input.bump === 'number' ? input.bump : 0 })
},
})| Catalog | Register | Item |
|---|---|---|
| Tools | ctx.tools.register(name, def) | A ToolDef (tools) |
| Agent profiles | ctx.agents.register(profile) | An AgentProfile (profiles) |
| Sub-agents | ctx.agents.subagents.register(def) | A sub-agent type for the spawn tool |
| Providers | ctx.providers.register(descriptor) | A ProviderDescriptor |
| MCP servers | ctx.mcp.register(config) | An McpServerConfig (MCP) |
| Skills | ctx.skills.register(skill) | A SkillConfig (skills) |
Models have no register: a provider's descriptor lists its models.
A registration with the id of an item already in the catalog replaces that item, and Settings records what it replaced. Pick ids that name your extension.
Transforms#
transform(fn) registers a function that edits the catalog through an editor. The app replays every transform, in extension load order, onto a fresh copy of the catalog whenever a registration changes or an extension calls reload().
ctx.mcp.transform((editor) => {
for (const server of editor.list()) {
if (server.name.startsWith('kitchen'))
editor.update(server.name, (s) => { s.toolTimeout = 60_000 })
}
})A transform that throws is rolled back as a whole: its edits are dropped, the error is logged against the extension, and the next transform runs.
A transform must be synchronous and pure: it reads the editor and what the extension already holds, and writes only through the editor. To base it on data from the network or a setting, load the data first, then call reload() when it changes. The kitchen-sink example replays its transforms when a setting changes (examples/extension-kitchen-sink/catalogs.ts):
const tweak = (): boolean => ctx.settings.get('tweakCatalogs') === true
ctx.models.transform((editor) => {
const current = editor.default.get()
if (tweak() && current && editor.get(current.providerId, current.modelId))
editor.update(current.providerId, current.modelId, (model) => { model.name = `${model.name} (tuned)` })
})
ctx.settings.onChange('tweakCatalogs', () => {
void Promise.all([ctx.agents.reload(), ctx.models.reload()])
})Every catalog with a key has the same editor:
| Member | Does |
|---|---|
list() | Every item, after the transforms that ran before this one |
get(id) | One item |
origin(id) | Who put it there: { kind: 'builtin' }, { kind: 'config' }, { kind: 'mcp', server } or { kind: 'extension', id } |
add(item) | Adds an item, or replaces the one with the same id |
update(id, edit) | Edits a copy of the item in edit, then stores it |
remove(id) | Removes the item |
Each catalog domain also has:
| Member | Does |
|---|---|
transform(fn) | Registers a transform. Returns a Disposable |
reload() | Replays every transform of this catalog. Resolves when done |
list() | The catalog after every transform |
Provenance#
A transform may edit or remove any item, built-in ones included. Settings shows which extension changed an item. Turning the extension off brings the item back as it was, because the next replay runs without that extension's transforms.
| App | Shows |
|---|---|
| Terminal app | A badge on each MCP server and skill row an extension changed. An item an extension removed stays listed |
| Desktop app | "Changed by extensions" in Settings › Extensions |
| Web app | The same list in its extension settings |
Tools#
ctx.tools.register(name, def) adds a tool the model can call. def is a ToolDef:
| Field | Does |
|---|---|
spec | { name, description, inputSchema }. name is what the model calls. Use the same value as name |
execute(input, toolCtx) | Runs the call. Returns a string or content blocks. A throw is attributed to the extension and counts toward its error budget |
isConcurrencySafe | true, or a function of the input, when the call only reads. Safe calls run in parallel. Default false |
The tools editor adds two fields to the common editor:
| Field | Holds |
|---|---|
phase | 'pre-run' when the host lists tools before a run (a tool picker), 'run' when it builds a run |
sessionId | The session the list is for |
ctx.tools.transform((editor) => {
if (editor.phase === 'run' && editor.get('web_search'))
editor.remove('web_search')
})The tools transform runs per run, with the run's tools as its base: built-in tools, MCP tools under their mcp_<server>_<tool> names, and extension tools. Its origin for an MCP tool is { kind: 'mcp', server }.
Tool metadata#
ctx.tools.metadata(namespace, spec) claims a namespace of tool-result metadata. A tool.result interceptor writes under it. The context panel reads it.
| Field | Does |
|---|---|
contextRows({ namespace, entries }) | Rows { label, value } for the context panel, from every entry. Without it, the panel shows a count |
summarize(metadata) | Reduces one result's metadata to a compact JSON summary |
ctx.tools.metadata('kitchen', {
contextRows: ({ entries }) => [{ label: 'Kitchen results', value: String(entries.length) }],
})Agents#
ctx.agents.register(profile) adds a profile to the agent picker. Profiles, families and switch notes are on the overview.
ctx.agents.subagents.register(def) adds a sub-agent type the spawn tool offers:
ctx.agents.subagents.register({
name: 'kitchen-taster',
description: 'Reads a file and says, in one line, what it is about.',
system: 'You read the file you are given and describe it in one line.',
tools: ['read_file'],
readonly: true,
})| Field | Does |
|---|---|
name | Required. What the model passes as subagent_type |
description | Shown to the model, to pick a type |
system | The sub-agent's system prompt |
model, thinking | Default model and thinking level |
tools | Tool names the sub-agent keeps, from the parent's tools |
readonly | Keeps only read_file, grep, glob and list_files. tools wins when both are set |
The agents editor adds:
| Member | Does |
|---|---|
default.get(), default.set(id) | The default profile |
subagents | An editor over sub-agent types, with override(name, fn): fn gets the current definition and returns the new one, or undefined to remove it |
ctx.agents.transform((editor) => {
editor.subagents.override('kitchen-taster', def => (def ? { ...def, thinking: 'low' } : def))
})Models#
The models catalog holds the models of the providers the user signed in to. Each ModelInfo has providerId, id, name, enabled, contextWindow, maxOutput, capabilities (tools, images, reasoning) and cost (input, output).
The models editor is keyed by provider and model:
| Member | Does |
|---|---|
list(providerId?) | Every model, or one provider's |
get(providerId, modelId) | One model |
update(providerId, modelId, edit) | Edits a copy of the model |
remove(providerId, modelId) | Hides the model |
default.get(), default.set(providerId, modelId) | The default model |
ctx.models.default() returns the default after every transform. For a provider whose accounts have lists of their own, ctx.models.list() and the editor list the active account's models.
Providers#
ctx.providers.register(descriptor) adds a provider to the pickers. The main fields:
| Field | Does |
|---|---|
key | Required. The registry key |
label, description | The name and one line, in pickers |
factory(options) | Required. Builds a Provider for a session |
models, defaultModel | The models it serves. A getter reads them when the picker opens |
envKey | The environment variable that holds an API key. undefined for a provider that only signs in |
integration | The integration it signs in with. Pickers, "Sign in" and usage follow it |
accounts | The factory honors options.account: pickers offer an account per session |
usage | A subscription usage probe |
The providers editor lists every descriptor, inactive ones included, keyed by key. Its models member edits a provider's model list: set(providerId, models, { account? }), update(providerId, modelId, edit) and remove(providerId, modelId).
ctx.providers.transform((editor) => {
for (const provider of editor.list()) {
if (editor.origin(provider.key)?.kind === 'builtin' && provider.description === undefined)
editor.update(provider.key, (p) => { p.description = 'Checked by kitchen-sink.' })
}
})To wrap a built provider's requests, use the provider and http.request interceptors.
Models per account#
One account of a provider can serve other models than another: a team plan, a region, a key with fewer rights. models.set takes an account, and then replaces that account's list only:
import type { ModelInfo } from 'zidane/extensions'
const byAccount = new Map<string, ModelInfo[]>() // each account's models, fetched with its credential
ctx.providers.transform((editor) => {
for (const [account, models] of byAccount)
editor.models.set('acme', models, { account })
})account is the account id the provider's sign-in stores: an integration connection's account, or the id of a stored provider account. An empty account throws. An account without a list of its own uses the provider's list, and set with an empty list gives an account back the provider's list.
- Pickers list each account's own models under that account. A session pinned to an account runs on its list: with no model picked, on the provider's default when the list has it, else on the list's first model. A model the user picked is never changed.
ctx.models.list()follows the active account (the default connection, or the stored account in use).models.updateandmodels.removereach the model in the provider's list and in every account's list. A model edit (ctx.modelsupdate,remove) applies to account lists by model id.- The provider transforms run again when the user connects or disconnects an account, or makes another one the default. A token refresh does not count.
MCP#
ctx.mcp registers servers, edits the list, and calls tools outside a run.
| Member | Does |
|---|---|
register(config) | Adds a server: { name, transport, command, args } for stdio, { name, transport, url, headers } for sse and streamable-http (MCP) |
transform(fn), reload() | Edits the server configs, keyed by name |
list() | Every connected server (status) |
call(server, tool, args?, { timeoutMs?, signal? }) | Calls a tool of a server outside a run. Resolves { content, isError } |
const result = await ctx.mcp.call('github', 'search_issues', { query: 'is:open label:bug' }, { timeoutMs: 15_000 })
if (!result.isError)
ctx.logger.info('issues', { content: result.content })call connects a server the extension registered when it is not connected yet, and closes it after 60 seconds without calls. Use the server's own tool name, not the mcp_<server>_<tool> name.
MCP status#
| Field | Holds |
|---|---|
name | The server's name |
status | connected, connecting, failed or disabled |
tools | The server's own tool names |
error | Why it failed |
url, command | Its URL (sse, streamable-http) or launch command (stdio) |
toolPrefix | mcp_<name>_. The agent's name of a tool is <toolPrefix><tool>: what tool.call and tool.result see |
list includes servers connected inside sessions, remote ones too.
Skills#
ctx.skills.register(skill) adds a skill. The skills editor adds autoinvoke(name, on): an auto-invoked skill is active at the start of every run.
ctx.skills.register({
name: 'kitchen-sink-guide',
description: 'Use when the user asks what the kitchen-sink extension does.',
instructions: 'The kitchen-sink extension shows every zidane extension API. Its commands start with /kitchen-.',
})
ctx.skills.transform((editor) => {
editor.autoinvoke('kitchen-sink-guide', ctx.settings.get('tweakCatalogs') === true)
})To pick skills per run from the prompt or the model, use the skills.activate interceptor.
Agent Plugins#
ctx.plugins.load(spec) loads an Agent Plugin and registers its skills and MCP servers. spec is a folder, relative to the extension's folder, or an object:
| Field | Does |
|---|---|
dir | The plugin's folder |
skills | Load its skills. Default true |
mcp | Load its MCP servers. Default true |
namePrefix | A prefix for the names of what it adds |
It resolves to the loaded plugin (ok, manifest, skills, mcpServers, diagnostics) plus dispose(). Check ok: a plugin the loader rejected loads nothing.
const plugin = await ctx.plugins.load({ dir: 'plugin', mcp: false })
if (!plugin.ok)
ctx.logger.warn(`plugin did not load: ${plugin.diagnostics.map(d => d.message).join('; ')}`)Instructions#
ctx.instructions.add(text, { sessionId?, key? }) adds text to the system prompt. Without sessionId, it applies to every session. A key replaces an earlier entry with the same key. The returned Disposable removes it.
ctx.instructions.add('When the user mentions the kitchen sink, answer with a cooking pun.')
ctx.instructions.add('This session looked at the counter at 3.', { sessionId: 'a-session-id', key: 'last-info' })Prefer instructions to the system interceptor for fixed text: the prompt stays the same from turn to turn, so the provider's cache keeps working.
Pitfalls#
- A transform that awaits, fetches or writes state runs at the wrong time, or more than once. Keep it synchronous and pure, and call
reload()when its inputs change. - A transform runs again on every replay. Do not count or log in it.
updateedits a copy. Changes to the objectgetreturned are lost.- A tool's
spec.nameand thenamepassed toregistershould match: the model callsspec.name. - MCP and
tool.callnames differ:ctx.mcp.calltakes the server's tool name, interceptors seemcp_<server>_<tool>. - An
updatemust keep the item's id.
Related#
- Hooks: interceptors for decisions made per call.
- Settings:
apply: 'agent-rebuild'for settings that transforms read. - Integrations: providers that sign in through an integration.