# Extensions

> Add tools, hooks, commands, settings, sign-ins and UI to the zidane apps with one TypeScript module, and bundle skills and MCP servers as Agent Plugins.

An extension is a TypeScript or JavaScript module that the zidane apps load at startup. Its `setup(ctx)` function registers what it adds: tools, hooks, commands, settings, catalog edits, sign-ins and UI. It runs in-process, in every app: the terminal app, the desktop app, and the server behind the web app.

This page is the overview and a quickstart. It also covers where extensions load from, [Agent Plugins](#agent-plugins) and [commands](#commands). Each area of the API has its own page, listed in [the context](#the-context).

## When to use one

| You want to | Use | Page |
| - | - | - |
| Teach the model a workflow in prose | A skill | [Skills](https://zidane.si/docs/skills.md) |
| Add tools that live in another process | An MCP server | [MCP](https://zidane.si/docs/mcp.md) |
| Ship skills and MCP servers together | An Agent Plugin | [Agent Plugins](#agent-plugins) |
| Reuse a prompt from the `/` menu | A Markdown command | [Markdown commands](#markdown-commands) |
| Run code: tools, rules on tool calls, settings, sign-ins, panels, keys | An extension | This page |

Pick an extension when you need code that runs in the app. Pick the others when text or a separate process is enough: they need no build and no trust.

## Quickstart

## 1. Create the folder

Put an `index.ts` in `~/.zidane/extensions/hello/`. The folder name is the extension's `id`.

## 2. Export a definition

```ts title="~/.zidane/extensions/hello/index.ts"
import { defineExtension } from 'zidane'

export default defineExtension({
  apiVersion: 1,
  id: 'hello',
  displayName: 'Hello',
  version: '0.1.0',
  description: 'Says hello and counts tool calls.',
  setup(ctx) {
    let calls = 0
    ctx.hooks.on('tool:after', () => {
      calls += 1
    })

    ctx.commands.register({
      id: 'greet',
      title: 'Say hello',
      slash: { name: 'hello' },
      bind: 'mod+shift+h',
      run: async () => {
        await ctx.ui.toast({ text: `Hello. ${calls} tool calls so far.` })
      },
    })
  },
})
```

## 3. Load it

Start the app, or type `/reload` in the terminal app. The extension shows in Settings › Extensions with an on/off switch.

## 4. Try it

Type `/hello`, press `Cmd`+`Shift`+`H` (`Ctrl`+`Shift`+`H` off macOS), or pick "Say hello" in the command palette. One `commands.register` call is all three.

`zidane extension create hello` writes the same folder with a test file and a `package.json` ([publishing](https://zidane.si/docs/extensions-publishing.md)). The [kitchen-sink example](https://github.com/Tahul/zidane/tree/main/examples/extension-kitchen-sink) uses every part of the API, one file per area. The pages of this section quote it.

## The definition

The module's default export is the definition. `defineExtension` returns its argument unchanged: it only types `ctx`. A plain object with a type-only import works too:

```ts
import type { ExtensionDefinition } from 'zidane'

export default {
  apiVersion: 1,
  id: 'hello',
  setup(ctx) {
    ctx.logger.info('loaded', { host: ctx.app.host })
  },
} satisfies ExtensionDefinition
```

| Field | Type | Does |
| - | - | - |
| `apiVersion` | `1` | Required. A module without it does not load |
| `id` | `string` | Required. Lowercase words joined by `-`, starting with a letter. Equals the folder name. Prefixes every id the extension adds: command `greet` is published as `hello.greet` |
| `displayName` | `string` | The name in Settings. Default: `id` |
| `icon` | `{ gui?, tui? }` | The extension's icon in the settings sidebar: `gui` an icon class for the desktop and web apps (`'i-ph-git-branch'`), `tui` a glyph for the terminal app. Without it, the sidebar shows the first letter of the name in a small square ([sidebar](https://zidane.si/docs/extensions-settings.md#pages-and-the-settings-sidebar)) |
| `version` | `string` | Semver, like `1.2.3` |
| `description` | `string` | One line in Settings |
| `dependencies` | `{ [id]: range }` | Other extensions this one builds on. Sets the load order and gives install hints. A missing one never fails the load ([lifecycle](https://zidane.si/docs/extensions-lifecycle.md#load-order)) |
| `settings` | `SettingsSchema` | Settings the extension reads. Shown even while it is off ([settings](https://zidane.si/docs/extensions-settings.md)) |
| `ui` | `string` | Entry with React components for the desktop and web apps. Default `ui.tsx` when it exists ([components](https://zidane.si/docs/extensions-components.md)) |
| `tui` | `string` | Entry with OpenTUI components for the terminal app. Default `tui.tsx` when it exists |
| `setup` | `(ctx) => void \| Cleanup \| Promise<…>` | Required. Runs once per load. May be async, and may return a cleanup ([lifecycle](https://zidane.si/docs/extensions-lifecycle.md)) |

## The context

`setup` gets `ctx`. Each area of the API is a namespace on it, and each has a page:

| `ctx` | Adds | Page |
| - | - | - |
| `scope`, `onCleanup`, `signal` | Lifetimes, cleanup and reload | [Lifecycle](https://zidane.si/docs/extensions-lifecycle.md) |
| `commands`, `keymap` | Palette entries, `/` commands and keybindings | [Commands](https://zidane.si/docs/extensions-commands.md) |
| `hooks` | Observers and interceptors of the agent loop and the hosts | [Hooks](https://zidane.si/docs/extensions-hooks.md) |
| `tools`, `agents`, `models`, `providers`, `mcp`, `skills`, `plugins`, `instructions` | Items in the catalogs, and edits to the built-in ones | [Catalogs](https://zidane.si/docs/extensions-catalogs.md) |
| `sessions`, `generate`, `workspace`, `artifacts` | Other sessions, model calls without a session, files and commands in the workspace, artifacts | [Sessions](https://zidane.si/docs/extensions-sessions.md) |
| `schedules` | Prompts and handlers that run at a set time, once or on a repeat | [Schedules](https://zidane.si/docs/extensions-schedules.md) |
| `storage` | Key/value data, typed stores, files | [Storage](https://zidane.si/docs/extensions-storage.md) |
| `settings` | Typed values the user sets in Settings | [Settings](https://zidane.si/docs/extensions-settings.md) |
| `integrations` | Accounts the user connects: API keys, OAuth, commands | [Integrations](https://zidane.si/docs/extensions-integrations.md) |
| `rpc`, `contracts` | Typed calls between extensions, their UI and clients | [RPC and contracts](https://zidane.si/docs/extensions-rpc.md) |
| `ui`, `composer` | Dialogs, toasts, panels, navigation, the composer | [UI](https://zidane.si/docs/extensions-ui.md) |
| `add` | Panels, slots, screens, renderers, themes, menus | [Placements](https://zidane.si/docs/extensions-placements.md) |
| `add` (sidebar tokens) | Spaces, sidebar filters, sorts and groupings, row decorations, rows of your own | [Sidebar](https://zidane.si/docs/extensions-sidebar.md) |
| `desktop` | Electron, in the desktop app only | [UI](https://zidane.si/docs/extensions-ui.md) |

`ctx` also holds facts about the extension and the app:

| Member | Holds |
| - | - |
| `id`, `version`, `source` | The definition's id and version. `source` is where it was found: `project`, `user`, `config`, `inline` or `builtin` |
| `dir` | The extension's folder, when it has one. Use it, not `import.meta.dirname` |
| `app` | `{ name: 'zidane', version, host, platform }`. `host` is `tui`, `desktop`, `daemon`, `server` or `sdk` |
| `paths` | `cwd`, `projectRoot`, `dataDir`, `cacheDir`, `configDir`, `stateDir` |
| `logger` | Logs tagged with the extension |
| `extensions` | `list()` and `get(id)`: every extension with its `status` (`active`, `disabled`, `failed`) and `error` |

## Where the code runs

An extension runs where the sessions run. A window that shows sessions from another process gets the extension's UI and commands from that process.

| App | Runs `setup` in | Notes |
| - | - | - |
| Terminal app | The app's own process. With the [daemon](https://zidane.si/docs/daemon.md) or `--host`, in the daemon or server | In daemon mode the window loads no extension code. It draws what the daemon sends, and loads `tui.tsx` from the folder the daemon resolved |
| Desktop app | The main process. With the daemon, in the daemon | `ctx.desktop` exists only in the main process of the desktop app. Its renderer loads `ui.tsx` |
| Web app | The server process | The browser loads `ui.tsx` from the server, and webviews from a server URL |

`ctx.app.host` tells which process runs the code. `ctx.sessions`, `ctx.generate`, `ctx.workspace`, `ctx.artifacts` and `ctx.plugins` need host support. On a host without it, a synchronous call throws and an async one rejects, with an error that names the call and the host.

## Where they load from

zidane looks for `extensions/<id>/index.ts` (or `.tsx`, `.mjs`, `.js`) in the [four config places](https://zidane.si/docs/files.md#files-you-write), in this order: `<project>/.agents/`, `<project>/.zidane/`, `~/.agents/`, `~/.zidane/`. The first folder for an id wins. `ZIDANE_EXTENSION_DIRS` adds more folders.

`import ... from 'zidane'` works in every app, the compiled binary included: the app hands the extension its own copy. The same goes for `zidane/extensions`, `zidane/extensions/testing`, `zidane/providers`, `zidane/providers/google` and `zidane/oauth`. Other packages load from the extension's own `node_modules`.

The apps reload one extension when a file in its folder changes, and leave the others running ([reload](https://zidane.si/docs/extensions-lifecycle.md#reload)). In Settings, turning an extension off unloads it. Its settings still show, because they come from the definition, not from `setup`.

A module without a valid definition is listed in Settings as inactive, with the reason: a missing `apiVersion: 1` or `id`, for example.

### Project trust

Project extensions run code from the repository that you opened. They stay off until you turn on "Load project extensions". In the terminal app, it is in Settings › Agent, group Project trust. In the desktop app, it is in Settings › Agent, group Approvals & safety. `ZIDANE_TRUST_PROJECT_EXTENSIONS=1` also turns it on.

An extension has no permissions to ask for. You trust it when you install it, and it can do what your user can do. The app still guards each call ([guards](https://zidane.si/docs/extensions-lifecycle.md#guards)).

## Install

Both apps install from GitHub or npm into the folders above, then load the extension. Terminal app: Settings › Extensions & Plugins, then `Ctrl`+`G`. Desktop app: Settings › Extensions, then Install.

Both open a browser on the extensions that ship with zidane (`Tahul/zidane`). Type a source to list its extensions, pick one or more, then choose your user folder or this project. The browser reads each extension's `package.json` and runs no code ([the `zidane` field](https://zidane.si/docs/extensions-publishing.md#the-zidane-field)). A private repository needs access ([private repositories](https://zidane.si/docs/extensions-publishing.md#private-repositories)).

| Source | Means |
| - | - |
| `owner/repo` | A GitHub repo with one extension |
| `github:owner/repo@v2` | A pinned branch or tag |
| `owner/repo indexer` | One extension from a repo that has several |
| `owner/repo#main:packages/ext` | A subfolder of a branch (pasted GitHub tree URLs work too) |
| `@scope/pkg@1.2.0` | An npm package, exact version or dist-tag |

Then pick the scope: global (`~/.zidane/extensions/`) or this project (`<project>/.zidane/extensions/`, needs project trust).

zidane imports and checks the extension in a temporary folder first, so a broken one installs nothing. It installs dependencies with the first of bun, npm, pnpm or yarn on your `PATH`, without install scripts. `.install.json` records the source for updates. In the desktop app, right-click Update to update from that source or to change it.

| Terminal key | Does |
| - | - |
| `Ctrl`+`U` | Update the selected extension from its recorded source |
| `Ctrl`+`O` | Change origin: install it again from another source with an extension of the same name. Updates then follow that source |
| `Ctrl`+`X` twice | Remove it |

### From a config file

An `extensions` array in a config file installs, pins and configures extensions. The user file is `<configDir>/config.json`. The project file is `<project>/.zidane/config.json`.

```json title=".zidane/config.json"
{
  "extensions": [
    { "source": "owner/repo" },
    { "source": "github:owner/repo@v1.2.0:extensions/goal", "settings": { "model": "anthropic/claude-sonnet-4-5" } },
    { "source": "owner/collection name", "enabled": false },
    { "source": "./tools/my-extension" }
  ]
}
```

Remote sources take the install grammar above. A local path (`./`, `../`, `/`, `~/`, `file:`) loads in place and is never copied. `enabled: false` keeps an extension installed and loads it turned off. The app that runs your extensions applies the file in the background at start and each time you save it; the project file only when project extensions are trusted. An entry that fails shows in Settings › Extensions with the reason ([when the apps apply it](https://zidane.si/docs/extensions-publishing.md#when-the-apps-apply-it)). `extensions.lock.json`, next to the config file, records what was installed, so a project can commit it. A setting given in `settings` shows locked in Settings ([locked values](https://zidane.si/docs/extensions-settings.md#locked-values)). Secret settings are refused there: they belong in the auth store.

### Native extensions

The `extensions/` folder of the zidane repository holds native extensions. The [setup wizard](https://zidane.si/docs/ui-onboarding.md) installs them in one click, from the tag of the release that you run. So each one matches the API of your app. To install one by hand, use `Tahul/zidane#v<version>:extensions/<name>`.

| Extension | Folder | Adds | Needs |
| - | - | - | - |
| Git flow | `git-flow` | Adds /commit, /review, /fix, /pr and /ticket. The agent writes your commits and pull requests, and reviews code with several models. | git, gh (optional) |
| Goal | `goal` | Adds /goal. The agent keeps working until the goal is done, for up to 10 runs. Esc stops it. | the decision model |
| Linear | `linear` | Shows Linear issues and projects as badges in prompts and replies, and links them to the chats that use them. | the Linear connector |
| AI autocomplete | `ai-autocomplete` | Suggests the end of your prompt as you type, from a local Ollama model. Press Tab to accept. | ollama (optional), docker (optional) |
| Bolt | `bolt` | Adds Bolt as a provider. Sign in with your Bolt account to use its models. | a Bolt account |

### Create one from the apps

Both apps scaffold a new extension from a name and a scope (you or this project). zidane writes a minimal `index.ts` and opens a session with the `zidane-extension` skill active, so the agent knows the extension API. Starting zidane inside an extension folder turns the skill on too.

## Agent profiles

`ctx.agents.register` adds a profile to the agent picker. Profiles with the same `family` share one agent: its tools are every member's tools, and its system prompt is the first member's, in registry order. A switch between members keeps the agent and the prompt cache. A gate refuses the tools the active profile does not have, and the profile's `switchNote` tells the model about the switch:

```ts
import { basicTools, definePreset, grep } from 'zidane'

const { readFile, edit, shell } = basicTools

ctx.agents.register({
  id: 'work',
  label: 'Work',
  description: 'reads, edits and runs commands',
  family: 'think-work',
  preset: definePreset({ name: 'work', system: 'You change the code.', tools: { readFile, grep, edit, shell } }),
})
ctx.agents.register({
  id: 'think',
  label: 'Think',
  description: 'reads and answers, changes nothing',
  family: 'think-work',
  switchNote: ({ tools }) => `Think mode: read only. Allowed tools: ${tools.join(', ')}.`,
  preset: definePreset({ name: 'think', system: 'You answer questions.', tools: { readFile, grep } }),
})
```

`switchNote` is a string, or a function of `{ tools, allowInteraction }`. By default the note gives the profile's label, description and system prompt, then the tools that are off. On the first member, the note is what the model gets when the session comes back to it.

A profile without a family has its own agent. A switch to a profile outside the session's family still keeps the agent when the profile's tools are a strict subset of the agent's tools (same names and schemas), as with Plan on a Codemode session. Otherwise the next run rebuilds the agent with the profile's tools and prompt, and the provider reads the conversation again. Editing or removing profiles, built-in ones included, is a [catalog transform](https://zidane.si/docs/extensions-catalogs.md#agents).

## Sessions and orchestration

`ctx.sessions` drives other sessions, acting as the extension: the verbs of [orchestration](https://zidane.si/docs/orchestration.md), plus model, agent and thinking switches, compaction, and messages the model sees or only the user sees. What the extension creates is tagged with its id. The full API is on [sessions](https://zidane.si/docs/extensions-sessions.md).

```ts
const id = await ctx.sessions.create({ cwd: ctx.paths.cwd, prompt: 'Fix the flaky test' })
await ctx.sessions.send(id, 'Keep going')
const { reason } = await ctx.sessions.wait(id, 30)
if (reason === 'blocked') {
  for (const request of await ctx.sessions.pending(id)) {
    if (request.kind === 'plan')
      await ctx.sessions.answer(request.requestId, { kind: 'plan', decision: 'approve' })
  }
}
```

## Composer badges

A badge in a session's composer is a slot item: `ctx.add(Slot, { at: 'composer.badges', sessionId, … })`. It returns a handle to update or remove it. An extension shows up to 8 per session. Slots and their fields are on [placements](https://zidane.si/docs/extensions-placements.md).

## Agent Plugins

An [Agent Plugin](https://agent-plugins.org) is a folder with a `plugin.json` manifest, a `skills/` folder and an `mcp.json`. zidane loads them from `plugins/` in the same four places as extensions (`<project>/.agents/plugins/` first), plus the folders in `ZIDANE_PLUGIN_DIRS`.

Each plugin has an on/off switch in Settings. A plugin that fails to load shows there with the reason. From code, `loadAgentPlugin` loads one ([reference](https://zidane.si/docs/reference/plugins.md)). An extension can ship a plugin in its folder and load it with `ctx.plugins.load` ([catalogs](https://zidane.si/docs/extensions-catalogs.md#agent-plugins)).

## Commands

A `/` command runs a workflow from the prompt. Sources:

| Source | Commands | Wins a name clash |
| - | - | - |
| Built-in | `/help`, `/refresh` (re-read the system prompt on the next message) and `/cache` (what the prompt cache did for the last requests, and when it expires) in both apps, and `/reload` (reload extensions) in the terminal app | First |
| Extensions | `ctx.commands.register` with `slash` | Second |
| Markdown files | `commands/*.md` | Last |

A skill also answers `/<name>` and wins over a Markdown command of the same name.

### Markdown commands

Each Markdown file in a `commands/` folder is a command. zidane reads the same four places as extensions. With "Import configurations from other agents" on, it also reads `.claude/commands`. A nested file gets a colon name, up to three folders deep: `review/pr.md` is `/review:pr`.

```md title=".zidane/commands/review.md"
---
description: Review a PR
argument-hint: <pr-number>
---
Review PR $1 and focus on $ARGUMENTS.
```

| In the body | Becomes |
| - | - |
| `$ARGUMENTS` | Everything typed after the command |
| `$1` to `$9` | One word of it each (empty when missing) |
| `\$` | A literal `$` |
| No placeholder | The arguments go after the body |

`description` defaults to the first line of the body. zidane ignores the frontmatter keys `model` and `allowed-tools`. Files in `agents/` folders are sub-agents ([sub-agents](https://zidane.si/docs/sub-agents.md#subagents-from-markdown-files)).

### Extension commands

One registration is a palette entry, a `/` command and a keybinding:

```ts
ctx.commands.register({
  id: 'deploy',
  title: 'Deploy a branch',
  slash: {
    name: 'deploy',
    argumentHint: '<branch>',
    complete: prefix => ['main', 'staging'].filter(b => b.startsWith(prefix)).map(value => ({ value })),
  },
  run: async ({ args, runAgent }) => {
    await runAgent?.(`Deploy ${args}`)
  },
})
```

`complete` fills the suggestions after `/deploy `. From the `/` menu, `run` also gets the step context: `emit`, `runAgent`, `generateData` and the rest. Every field is on [commands](https://zidane.si/docs/extensions-commands.md).

## Pitfalls

- The `id` must equal the folder name, or discovery refuses the extension.
- `import.meta.dirname` is `undefined` in the desktop app. Use `ctx.dir`.
- Name a backend file `ui.ts` and it clashes with `ui.tsx`: `./ui` resolves to `ui.tsx` first.
- Do not write `.ts` or `.js` data files into the extension's folder. The apps reload the extension when its source files change. Use [`ctx.storage.path`](https://zidane.si/docs/extensions-storage.md#files).
- `ctx.desktop` is `undefined` outside the desktop app's main process. Check it before use.
- A dialog rejects with `NoUiError` when no window can show it, for example from a keybinding on a headless server ([UI](https://zidane.si/docs/extensions-ui.md)).

## Related

- [Lifecycle](https://zidane.si/docs/extensions-lifecycle.md): load, scopes, cleanup, reload and guards.
- [Testing](https://zidane.si/docs/extensions-testing.md): `createTestHost` drives an extension without an app.
- [Publishing](https://zidane.si/docs/extensions-publishing.md): pack, publish and install.
- [Cookbook](https://zidane.si/docs/extensions-cookbook.md): recipes that combine the areas.
- [Extensions reference](https://zidane.si/docs/reference/extensions.md): every exported type.
