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 and commands. Each area of the API has its own page, listed in the context.
When to use one#
| You want to | Use | Page |
|---|---|---|
| Teach the model a workflow in prose | A skill | Skills |
| Add tools that live in another process | An MCP server | MCP |
| Ship skills and MCP servers together | An Agent Plugin | Agent Plugins |
Reuse a prompt from the / menu | A Markdown command | 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#
Create the folder
Put an
index.tsin~/.zidane/extensions/hello/. The folder name is the extension'sid.Export a definition
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.` }) }, }) }, })Load it
Start the app, or type
/reloadin the terminal app. The extension shows in Settings › Extensions with an on/off switch.Try it
Type
/hello, press CmdShiftH (CtrlShiftH off macOS), or pick "Say hello" in the command palette. Onecommands.registercall is all three.
zidane extension create hello writes the same folder with a test file and a package.json (publishing). The kitchen-sink example 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:
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) |
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) |
settings | SettingsSchema | Settings the extension reads. Shown even while it is off (settings) |
ui | string | Entry with React components for the desktop and web apps. Default ui.tsx when it exists (components) |
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) |
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 |
commands, keymap | Palette entries, / commands and keybindings | Commands |
hooks | Observers and interceptors of the agent loop and the hosts | Hooks |
tools, agents, models, providers, mcp, skills, plugins, instructions | Items in the catalogs, and edits to the built-in ones | Catalogs |
sessions, generate, workspace, artifacts | Other sessions, model calls without a session, files and commands in the workspace, artifacts | Sessions |
schedules | Prompts and handlers that run at a set time, once or on a repeat | Schedules |
storage | Key/value data, typed stores, files | Storage |
settings | Typed values the user sets in Settings | Settings |
integrations | Accounts the user connects: API keys, OAuth, commands | Integrations |
rpc, contracts | Typed calls between extensions, their UI and clients | RPC and contracts |
ui, composer | Dialogs, toasts, panels, navigation, the composer | UI |
add | Panels, slots, screens, renderers, themes, menus | Placements |
add (sidebar tokens) | Spaces, sidebar filters, sorts and groupings, row decorations, rows of your own | Sidebar |
desktop | Electron, in the desktop app only | UI |
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 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, 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). 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).
Install#
Both apps install from GitHub or npm into the folders above, then load the extension. Terminal app: Settings › Extensions & Plugins, then CtrlG. 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). A private repository needs access (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 |
|---|---|
| CtrlU | Update the selected extension from its recorded source |
| CtrlO | Change origin: install it again from another source with an extension of the same name. Updates then follow that source |
| CtrlX 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.
{
"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). 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). 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 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:
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.
Sessions and orchestration#
ctx.sessions drives other sessions, acting as the extension: the verbs of orchestration, 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.
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.
Agent Plugins#
An Agent Plugin 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). An extension can ship a plugin in its folder and load it with ctx.plugins.load (catalogs).
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.
---
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).
Extension commands#
One registration is a palette entry, a / command and a keybinding:
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.
Pitfalls#
- The
idmust equal the folder name, or discovery refuses the extension. import.meta.dirnameisundefinedin the desktop app. Usectx.dir.- Name a backend file
ui.tsand it clashes withui.tsx:./uiresolves toui.tsxfirst. - Do not write
.tsor.jsdata files into the extension's folder. The apps reload the extension when its source files change. Usectx.storage.path. ctx.desktopisundefinedoutside the desktop app's main process. Check it before use.- A dialog rejects with
NoUiErrorwhen no window can show it, for example from a keybinding on a headless server (UI).
Related#
- Lifecycle: load, scopes, cleanup, reload and guards.
- Testing:
createTestHostdrives an extension without an app. - Publishing: pack, publish and install.
- Cookbook: recipes that combine the areas.
- Extensions reference: every exported type.