Skip to content

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 toUsePage
Teach the model a workflow in proseA skillSkills
Add tools that live in another processAn MCP serverMCP
Ship skills and MCP servers togetherAn Agent PluginAgent Plugins
Reuse a prompt from the / menuA Markdown commandMarkdown commands
Run code: tools, rules on tool calls, settings, sign-ins, panels, keysAn extensionThis 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. 1

    Create the folder

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

  2. 2

    Export a definition

    ~/.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. 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. 4

    Try it

    Type /hello, press CmdShiftH (CtrlShiftH 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). 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:

TypeScript
import type { ExtensionDefinition } from 'zidane'

export default {
  apiVersion: 1,
  id: 'hello',
  setup(ctx) {
    ctx.logger.info('loaded', { host: ctx.app.host })
  },
} satisfies ExtensionDefinition
FieldTypeDoes
apiVersion1Required. A module without it does not load
idstringRequired. 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
displayNamestringThe 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)
versionstringSemver, like 1.2.3
descriptionstringOne 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)
settingsSettingsSchemaSettings the extension reads. Shown even while it is off (settings)
uistringEntry with React components for the desktop and web apps. Default ui.tsx when it exists (components)
tuistringEntry 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:

ctxAddsPage
scope, onCleanup, signalLifetimes, cleanup and reloadLifecycle
commands, keymapPalette entries, / commands and keybindingsCommands
hooksObservers and interceptors of the agent loop and the hostsHooks
tools, agents, models, providers, mcp, skills, plugins, instructionsItems in the catalogs, and edits to the built-in onesCatalogs
sessions, generate, workspace, artifactsOther sessions, model calls without a session, files and commands in the workspace, artifactsSessions
schedulesPrompts and handlers that run at a set time, once or on a repeatSchedules
storageKey/value data, typed stores, filesStorage
settingsTyped values the user sets in SettingsSettings
integrationsAccounts the user connects: API keys, OAuth, commandsIntegrations
rpc, contractsTyped calls between extensions, their UI and clientsRPC and contracts
ui, composerDialogs, toasts, panels, navigation, the composerUI
addPanels, slots, screens, renderers, themes, menusPlacements
add (sidebar tokens)Spaces, sidebar filters, sorts and groupings, row decorations, rows of your ownSidebar
desktopElectron, in the desktop app onlyUI

ctx also holds facts about the extension and the app:

MemberHolds
id, version, sourceThe definition's id and version. source is where it was found: project, user, config, inline or builtin
dirThe 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
pathscwd, projectRoot, dataDir, cacheDir, configDir, stateDir
loggerLogs tagged with the extension
extensionslist() 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.

AppRuns setup inNotes
Terminal appThe app's own process. With the daemon or --host, in the daemon or serverIn 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 appThe main process. With the daemon, in the daemonctx.desktop exists only in the main process of the desktop app. Its renderer loads ui.tsx
Web appThe server processThe 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).

SourceMeans
owner/repoA GitHub repo with one extension
github:owner/repo@v2A pinned branch or tag
owner/repo indexerOne extension from a repo that has several
owner/repo#main:packages/extA subfolder of a branch (pasted GitHub tree URLs work too)
@scope/pkg@1.2.0An 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 keyDoes
CtrlUUpdate the selected extension from its recorded source
CtrlOChange origin: install it again from another source with an extension of the same name. Updates then follow that source
CtrlX twiceRemove 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.

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

ExtensionFolderAddsNeeds
Git flowgit-flowAdds /commit, /review, /fix, /pr and /ticket. The agent writes your commits and pull requests, and reviews code with several models.git, gh (optional)
GoalgoalAdds /goal. The agent keeps working until the goal is done, for up to 10 runs. Esc stops it.the decision model
LinearlinearShows Linear issues and projects as badges in prompts and replies, and links them to the chats that use them.the Linear connector
AI autocompleteai-autocompleteSuggests the end of your prompt as you type, from a local Ollama model. Press Tab to accept.ollama (optional), docker (optional)
BoltboltAdds 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:

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

TypeScript
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:

SourceCommandsWins 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 appFirst
Extensionsctx.commands.register with slashSecond
Markdown filescommands/*.mdLast

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.

.zidane/commands/review.md
---
description: Review a PR
argument-hint: <pr-number>
---
Review PR $1 and focus on $ARGUMENTS.
In the bodyBecomes
$ARGUMENTSEverything typed after the command
$1 to $9One word of it each (empty when missing)
\$A literal $
No placeholderThe 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:

TypeScript
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 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.
  • 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).