# 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.

```ts
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](https://zidane.si/docs/tools.md)) |
| Agent profiles | `ctx.agents.register(profile)` | An `AgentProfile` ([profiles](https://zidane.si/docs/extensions.md#agent-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](https://zidane.si/docs/mcp.md)) |
| Skills | `ctx.skills.register(skill)` | A `SkillConfig` ([skills](https://zidane.si/docs/skills.md)) |

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()`.

```ts
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`):

```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](https://zidane.si/docs/extensions-lifecycle.md#guards) |
| `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 |

```ts
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`](https://zidane.si/docs/extensions-hooks.md#toolresult) 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 |

```ts
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](https://zidane.si/docs/extensions.md#agent-profiles).

`ctx.agents.subagents.register(def)` adds a sub-agent type the `spawn` tool offers:

```ts
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 |

```ts
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](#models-per-account), `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](https://zidane.si/docs/extensions-integrations.md#providers-that-sign-in) 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)`.

```ts
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`](https://zidane.si/docs/extensions-hooks.md#provider) and [`http.request`](https://zidane.si/docs/extensions-hooks.md#httprequest-and-httpresponse) 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:

```ts
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](https://zidane.si/docs/extensions-integrations.md) 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.update` and `models.remove` reach the model in the provider's list and in every account's list. A model edit (`ctx.models` `update`, `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](https://zidane.si/docs/mcp.md)) |
| `transform(fn)`, `reload()` | Edits the server configs, keyed by `name` |
| `list()` | Every connected server ([status](#mcp-status)) |
| `call(server, tool, args?, { timeoutMs?, signal? })` | Calls a tool of a server outside a run. Resolves `{ content, isError }` |

```ts
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.

```ts
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`](https://zidane.si/docs/extensions-hooks.md#skillsactivate) interceptor.

## Agent Plugins

`ctx.plugins.load(spec)` loads an [Agent Plugin](https://zidane.si/docs/extensions.md#agent-plugins) 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.

```ts
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.

```ts
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`](https://zidane.si/docs/extensions-hooks.md#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.
- `update` edits a copy. Changes to the object `get` returned are lost.
- A tool's `spec.name` and the `name` passed to `register` should match: the model calls `spec.name`.
- MCP and `tool.call` names differ: `ctx.mcp.call` takes the server's tool name, interceptors see `mcp_<server>_<tool>`.
- An `update` must keep the item's id.

## Related

- [Hooks](https://zidane.si/docs/extensions-hooks.md): interceptors for decisions made per call.
- [Settings](https://zidane.si/docs/extensions-settings.md): `apply: 'agent-rebuild'` for settings that transforms read.
- [Integrations](https://zidane.si/docs/extensions-integrations.md): providers that sign in through an integration.
