# Extensions
URL: /en-US/docs/develop/extensions

Create extensions with the npm create wizard or by hand, and run your own extension marketplace.



An extension is a folder with a `.spirit/extension.json` — the Spirit manifest dump — next to a pure-npm `package.json`. Spirit installs extensions per user and per host, not under the workspace `.spirit/` directory, and scopes each install by the marketplace it came from: the install identity is `(source id, name)`.

| Host    | Path                                                    |
| ------- | ------------------------------------------------------- |
| Desktop | `{spiritDataDir}/extensions/desktop/<sourceId>/<name>/` |
| CLI     | `{spiritDataDir}/extensions/cli/<sourceId>/<name>/`     |

That is different from [Skills](../customize/skills.mdx) (`SKILL.md` folders), [MCP](../customize/mcp.mdx) (external servers), and [Hooks](../customize/hooks.mdx) (`hooks.json` scripts). `contributes.cli.hooks` styles CLI TUI slots. It is not `hooks.json`. An extension can still *bundle* those same files when it declares `mcp`, `hooks`, `skills`, or `rules`.

Each installed extension has an enable switch. Disable or uninstall it to stop its contributions. Turning the extension on is consent to run the MCP servers, agent hooks, skills, and rules it declared.

## Create an extension [#create-an-extension]

<Tabs items="[&#x22;Command line&#x22;, &#x22;Manual&#x22;]">
  <Tab value="Command line">
    Scaffold a new extension with the create wizard:

    <Tabs items="[&#x22;npm&#x22;, &#x22;pnpm&#x22;, &#x22;Bun&#x22;, &#x22;Yarn&#x22;, &#x22;Deno&#x22;]">
      <Tab value="npm">
        ```bash
        npm create @spiritagent/extension
        ```
      </Tab>

      <Tab value="pnpm">
        ```bash
        pnpm create @spiritagent/extension
        ```
      </Tab>

      <Tab value="Bun">
        ```bash
        bun create @spiritagent/extension
        ```
      </Tab>

      <Tab value="Yarn">
        ```bash
        yarn create @spiritagent/extension
        ```
      </Tab>

      <Tab value="Deno">
        ```bash
        deno init --npm @spiritagent/extension
        ```
      </Tab>
    </Tabs>

    The wizard asks for an extension name (kebab-case), a display name, a one-line description, and the capabilities to scaffold. It writes `package.json`, `icon.svg`, and `.spirit/extension.json`, plus the files for the capabilities you picked (`skills/<name>/SKILL.md`, `rule.md`, `mcp.json`, `hooks.json`, `styles.css`, `index.mjs`), and prints a ready-to-paste marketplace entry when it finishes.

    For scripting, every prompt has a flag: `[target-dir]`, `--name`, `--display-name`, `--description`, `--capabilities <csv>`, and `--yes` to skip confirmation.

    Validate the result at any time:

    ```bash
    npx @spiritagent/extension-toolkit check ./my-extension
    ```
  </Tab>

  <Tab value="Manual">
    An extension directory needs only two files — `.spirit/extension.json` and `package.json` — plus the capability files it declares:

    ```text
    my-extension/
      .spirit/
        extension.json        # Spirit manifest dump (required)
      package.json            # pure npm; only `main` is read
      icon.svg                # optional; SVG only, no <script>
      index.mjs               # optional; the activate entry referenced by `main`
      mcp.json                # when declaring mcp
      hooks.json              # when declaring hooks (agent hooks)
      rule.md                 # when declaring rules
      skills/<name>/SKILL.md  # when declaring skills
      styles.css              # when declaring desktop CSS (path referenced from the manifest)
      cli-hooks.json          # when declaring CLI hooks (path referenced from the manifest)
    ```

    `package.json` carries no Spirit fields — `name`, `version`, `description`, `author`, `homepage`, `license`, `main`, `keywords` stay pure npm. The host reads only `main`, the module entry for [`activate`](#activate).

    `.spirit/extension.json` fields:

    | Field           | Description                                                                                                                                   |
    | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
    | `schemaVersion` | Required. Must be `1`                                                                                                                         |
    | `name`          | Required. kebab-case; the entry id inside a marketplace                                                                                       |
    | `version`       | Required. Semver `major.minor.patch`; pre-release suffixes are not supported                                                                  |
    | `sourceId`      | Required. Owning marketplace source id. ZIP packages self-declare any value (for example `"self-declared"`); the install pipeline rewrites it |
    | `displayName`   | Required. User-visible name                                                                                                                   |
    | `description`   | Required. One-line summary                                                                                                                    |
    | `icon`          | Optional. SVG path relative to the extension root                                                                                             |
    | `author`        | Optional. `{ name, email?, url? }` or an npm-style `"Name <email> (url)"` string                                                              |
    | `category`      | Optional. Single string                                                                                                                       |
    | `keywords`      | Optional. String array of search keywords                                                                                                     |
    | `homepage`      | Optional. Project URL                                                                                                                         |
    | `manifest`      | Required. The runtime declaration — see [The manifest](#the-manifest)                                                                         |

    The dump carries no `source` or curation fields (`reviewStatus`, `featured`) — those belong to the marketplace entry.

    Declared files must exist and be valid: declaring `mcp`, `hooks`, `skills`, or `rules` without the matching file fails validation and install. Files present but not declared are ignored and never loaded.
  </Tab>
</Tabs>

## The manifest [#the-manifest]

The `manifest` object is the extension's runtime declaration: which hosts it supports, when it activates, and what it contributes. In a marketplace entry, the manifest is endorsed by the marketplace operator; in a ZIP package, it is self-declared and gated by the review confirmation.

### Capabilities [#capabilities]

| Value                | Runtime                                                                   |
| -------------------- | ------------------------------------------------------------------------- |
| `tool-definitions`   | Required with `tool-execution` to expose `contributes.tools` to the model |
| `tool-execution`     | Required with `tool-definitions` to run those tools                       |
| `system-prompt`      | Required with `main` to contribute a system prompt fragment               |
| `desktop-ui`         | Must be paired with `contributes.desktop`                                 |
| `cli-ui`             | Must be paired with `contributes.cli`                                     |
| `mcp`                | Must be paired with `contributes.mcp`; loads package-root `mcp.json`      |
| `hooks`              | Must be paired with `contributes.hooks`; loads package-root `hooks.json`  |
| `skills`             | Must be paired with `contributes.skills`; loads `skills/<name>/SKILL.md`  |
| `rules`              | Must be paired with `contributes.rules`; loads package-root `rule.md`     |
| `approval-flow`      | Declared only. Approval is driven by each tool's `approvalMode`           |
| `questions-flow`     | Declared only. Questions are driven by `approvalMode: need-questions`     |
| `settings`           | Declared only. Settings come from `settingsSchema`                        |
| `secret-storage`     | Used with `secretSlots`                                                   |
| `structured-results` | Declared only                                                             |

`desktop-ui` / `cli-ui` / `mcp` / `hooks` / `skills` / `rules` and the matching `contributes` entry must both be present or both be absent.

### Activation events [#activation-events]

| Event                  | When it fires                    |
| ---------------------- | -------------------------------- |
| `onStartup`            | Host warmup                      |
| `onExtensionInstalled` | After an install completes       |
| `onSessionOpened`      | Session becomes active           |
| `onSessionReset`       | Session reset                    |
| `onUserMessage`        | User submits a message           |
| `onToolCall`           | A tool is about to run           |
| `onToolResult`         | A tool result is available       |
| `onApprovalResolved`   | An approval decision is resolved |

The host calls `activate` when the extension lists the event and has a readable `main`.

### `contributes.tools` [#contributestools]

| Field           | Description                                               |
| --------------- | --------------------------------------------------------- |
| `name`          | Required. `[a-z0-9]+(?:[._-][a-z0-9]+)*`                  |
| `description`   | Required. Shown to the model                              |
| `inputSchema`   | Required. JSON Schema object                              |
| `outputSchema`  | Optional. JSON Schema object                              |
| `approvalMode`  | Optional. `allowed`, `need-approval`, or `need-questions` |
| `executionMode` | Optional. `foreground` or `background`                    |

The model does not see `name` as-is. The host builds an invocation name such as `extension__{id}__{tool}__{hash}`.

### `contributes.desktop` [#contributesdesktop]

Requires `desktop-ui`.

| Field            | Description                                                                                                                                                                    |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `css[].path`     | Required. CSS file relative to the package root                                                                                                                                |
| `css[].media`    | Optional. CSS media query                                                                                                                                                      |
| `settingsPage`   | Optional. `true`, `{}`, or `{ title }` — adds a Desktop settings entry                                                                                                         |
| `views[].id`     | Required. Kebab-case id, unique within the extension                                                                                                                           |
| `views[].path`   | Required. Precompiled single-file ESM (`.js` / `.mjs`) relative to the package root. The host does not compile TSX. Relative imports 404 — the protocol only serves this file. |
| `views[].title`  | Optional. Dialog title                                                                                                                                                         |
| `views[].width`  | Optional. Dialog width                                                                                                                                                         |
| `views[].height` | Optional. Max dialog height. Omit to hug the view. The iframe scrolls if the view is taller.                                                                                   |
| `views[].chrome` | Optional. Dialog chrome: `default` (title + close), `close-only` (hide title, keep close), or `none` (hide both). Omit for `default`.                                          |

Default export: `export default function View({ params, close })`. `close(result)` finishes the view. The host injects React and `@spirit/desktop-ui`.

### `contributes.cli` [#contributescli]

Requires `cli-ui`. `hooks` is an object with `path`, not an inline array:

```json
{
  "hooks": { "path": "cli-hooks.json" }
}
```

The file must be `{ "hooks": [ ... ] }`.

| Field     | Description                                                              |
| --------- | ------------------------------------------------------------------------ |
| `slot`    | Required. One of the slots below                                         |
| `variant` | Optional. `default`, `accented`, `muted`, `warning`, `success`, `danger` |
| `tokens`  | Optional. `{ foreground?, border?, accent? }`                            |
| `prefix`  | Optional. String                                                         |
| `suffix`  | Optional. String                                                         |

Slots: `message.user`, `message.assistant`, `message.tool`, `assistant.thinking`, `input.frame`, `bottom_form`, `bottom_form.section`, `slash_suggestions`, `approval.panel`, `questions.panel`.

Token roles: `default`, `primary`, `secondary`, `muted`, `accent`, `success`, `warning`, `danger`.

```json
{
  "hooks": [
    {
      "slot": "input.frame",
      "variant": "accented",
      "tokens": { "border": "accent" },
      "prefix": "[",
      "suffix": "]"
    }
  ]
}
```

### Bundled MCP, hooks, skills, and rules [#bundled-mcp-hooks-skills-and-rules]

Requires the matching capability. `contributes.mcp`, `contributes.hooks`, `contributes.skills`, and `contributes.rules` are `true` or `{}`. Paths are conventional; custom paths are not allowed.

| Contribution | Capability | File                                                                                |
| ------------ | ---------- | ----------------------------------------------------------------------------------- |
| MCP          | `mcp`      | Package-root `mcp.json` (same schema as user/workspace MCP)                         |
| Agent hooks  | `hooks`    | Package-root `hooks.json` (same schema as agent hooks; not `contributes.cli.hooks`) |
| Skills       | `skills`   | `skills/<name>/SKILL.md`                                                            |
| Rules        | `rules`    | Package-root `rule.md`                                                              |

Declared but missing or invalid files fail install. Files in the package that are not declared are ignored and never loaded.

Relative `command` and `cwd` values resolve against the extension root. If an MCP stdio server omits `cwd`, it defaults to the extension root. PATH-only commands are unchanged.

These contributions overlay into the agent session when the extension is enabled. They do not appear in **Settings → MCPs | Hooks | Skills | Rules**. On a name conflict, workspace wins, then user, then extension.

During `tools/call`, a bundled MCP server may send a JSON-RPC request `spirit/ui/open` with `{ viewId, params }` when the client advertised `experimental["spirit/ui"]`. Spirit only opens a view declared by the same extension in `contributes.desktop.views`. User and workspace MCP servers are rejected. The Dialog stays open while that `tools/call` is in flight; `close(result)` returns on the same request. Hosts without a window respond `{ "kind": "unavailable", "reason": "host-has-no-ui" }` — the tool should degrade.

### `settingsSchema` [#settingsschema]

| Field          | Description                                                      |
| -------------- | ---------------------------------------------------------------- |
| `key`          | Required. Same pattern as tool names                             |
| `type`         | Required. `string`, `boolean`, `number`, or `select`             |
| `title`        | Required. UI label                                               |
| `description`  | Optional                                                         |
| `placeholder`  | Optional                                                         |
| `required`     | Optional. Boolean                                                |
| `defaultValue` | Optional. Must match `type`                                      |
| `options`      | Required for `select`. Array of `{ value, label, description? }` |

Values are `string`, `number`, `boolean`, or `null`. `null` clears a non-required setting.

### `secretSlots` [#secretslots]

| Field         | Description       |
| ------------- | ----------------- |
| `key`         | Required          |
| `title`       | Required          |
| `description` | Optional          |
| `required`    | Optional. Boolean |

Desktop stores secrets in the OS keyring. On the CLI daemon path, `secrets.set` / `secrets.delete` may be unavailable.

## `activate` [#activate]

`main` is loaded with dynamic `import`. Export one of:

* `export function activate(ctx) { ... }`
* `export default function activate(ctx) { ... }`
* `export default { activate }`

### `ctx` [#ctx]

| Field             | Description                                                                                       |
| ----------------- | ------------------------------------------------------------------------------------------------- |
| `extension`       | `{ id, name, version, directoryPath, manifestPath, main }`                                        |
| `host`            | Host API. Desktop implements `showMessageBox` and `ui.open` / `ui.close`. CLI is `{}`             |
| `log`             | `(message: string) => void`                                                                       |
| `settings`        | `get(key)`, `getAll()`, `set(key, value)`, `setAll(values)`                                       |
| `secrets`         | `get(key)`, `has(key)`, `set(key, value)`, `delete(key)` — keys must be declared in `secretSlots` |
| `activationEvent` | Optional. `{ type, detail? }`                                                                     |

### Return value [#return-value]

Return an object, or export the same fields from the module.

| Field             | Description                                                                    |
| ----------------- | ------------------------------------------------------------------------------ |
| `tools`           | `Record<string, (ctx) => unknown>`. Keys are manifest tool names               |
| `invokeTool`      | `(ctx) => unknown`. Used instead of `tools[name]` when present                 |
| `systemPrompt`    | Static system fragment                                                         |
| `getSystemPrompt` | `() => string \| Promise<string>`. Used instead of `systemPrompt` when present |
| `onEvent`         | `(event) => void`                                                              |
| `dispose`         | `() => void`. Called on remove or reload                                       |

### Tool handler `ctx` [#tool-handler-ctx]

| Field             | Description                                           |
| ----------------- | ----------------------------------------------------- |
| `extension`       | Same runtime info as `activate`                       |
| `host`            | Same host API                                         |
| `toolName`        | Manifest tool name                                    |
| `arguments`       | Object from the model                                 |
| `log`             | Same logger                                           |
| `settings`        | Same settings accessor                                |
| `secrets`         | Same secrets accessor                                 |
| `toolCallId`      | Optional                                              |
| `questionsResult` | Optional. Set when `approvalMode` is `need-questions` |

A `string` result is passed through. Other values are `JSON.stringify(value, null, 2)`. `undefined` becomes `""`.

### Desktop `host.showMessageBox` [#desktop-hostshowmessagebox]

| Field       | Description                                              |
| ----------- | -------------------------------------------------------- |
| `title`     | Required. String                                         |
| `message`   | Required. String                                         |
| `detail`    | Optional. String                                         |
| `buttons`   | Optional. String array                                   |
| `cancelId`  | Optional. Number                                         |
| `defaultId` | Optional. Number                                         |
| `noLink`    | Optional. Boolean                                        |
| `type`      | Optional. `none`, `info`, `error`, `question`, `warning` |

```js
export function activate(ctx) {
  return {
    systemPrompt: "Use lookup_item when the user asks for an item by id.",
    async invokeTool({ toolName, arguments: args, settings, secrets }) {
      if (toolName !== "lookup_item") {
        throw new Error(`Unknown tool: ${toolName}`);
      }
      const region = await settings.get("region");
      const token = await secrets.get("api_token");
      return { id: args.id, region, hasToken: Boolean(token) };
    },
    dispose() {
      ctx.log("example-extension disposed");
    },
  };
}
```

## Distribute as a ZIP [#distribute-as-a-zip]

Pack an extension directory into the shareable ZIP format (content plus `.spirit/extension.json`):

```bash
npx @spiritagent/extension-toolkit pack ./my-extension
```

The command validates the package first and writes `<name>-<version>.zip` to the current directory. Users import the ZIP from the **Extensions** page or with `spirit extension import`; it lands in their Personal marketplace. See [Extensions](../customize/extensions.mdx).

To list the extension in a marketplace instead, see [Publish to a marketplace](#publish-to-a-marketplace).

## Run a marketplace [#run-a-marketplace]

A marketplace is a servable directory with a `.spirit/marketplace.json` index: a Git repository, a static site, or a local directory. Users add it with `spirit extension marketplace add <url|path>` or from the Desktop **Extensions** page, then browse and install its entries.

<Tabs items="[&#x22;Template repository&#x22;, &#x22;Manual&#x22;]">
  <Tab value="Template repository">
    [Use this template](https://github.com/new?template_owner=SpiritAgents\&template_name=extension-template) to create your own marketplace repository from `SpiritAgents/extension-template`.

    The template ships the registry layout, two example extensions, and CI that validates the registry on every push and pull request. To publish an extension:

    1. Scaffold it with `npm create @spiritagent/extension`.
    2. Run `npx @spiritagent/extension-toolkit publish ./my-extension /path/to/your-marketplace-clone` — it copies the extension into `extensions/` and merges the entry into `.spirit/marketplace.json`. See [Publish to a marketplace](#publish-to-a-marketplace).
    3. Push — users add the marketplace with its Git URL.
  </Tab>

  <Tab value="Manual">
    A marketplace root holds the index plus one content directory per extension:

    ```text
    my-marketplace/
      .spirit/
        marketplace.json  # the index
      extensions/
        my-extension/     # content directory (plain npm package + capability files)
          icon.svg
          package.json
          ...
    ```

    Relative paths inside `marketplace.json` resolve against the marketplace root and must not contain `..` segments or absolute paths.

    Entry fields, by group:

    * **Identity** — `name` (kebab-case; the entry id), `version` (semver, no pre-release), and `source`: a root-relative path string like `"./extensions/my-extension"` for local content, or `{ "source": "npm", "package": "@scope/name@1.0.0" }` to pin an exact npm version (integrity is verified against the packument's SRI hash at install time).
    * **Display** — `displayName`, `description`, `icon` (root-relative SVG path), `category`, `keywords`, `homepage`, `author`.
    * **Declaration** — `manifest`: the runtime declaration described in [The manifest](#the-manifest). The marketplace endorses it; a package cannot assert capabilities on its own.
    * **Curation** — `featured` and `reviewStatus` (`unverified` / `verified` / `revoked`).

    `extension-toolkit publish` can merge the entry for you — see [Publish to a marketplace](#publish-to-a-marketplace).

    Validate the registry locally — offline: schema, kebab-case names, semver without pre-release, npm source format, declared-contribution files for local sources, and icon rules:

    ```bash
    npx -y @spiritagent/extension-toolkit marketplace check .
    ```

    Review discipline: pull requests that add an entry or change its `version` must keep `reviewStatus: "unverified"`; promote to `verified` in a separate commit after a human review. The template repository's `.github/workflows/marketplace.yml` is a reference implementation — it diffs the index against the PR base.
  </Tab>
</Tabs>

### Local marketplaces [#local-marketplaces]

Any local directory with a `.spirit/marketplace.json` works as a marketplace — add it with `spirit extension marketplace add <path>` or Desktop **Add from Disk**. Local marketplaces are read in place and need no CI, which makes them the development loop for extension authors: edit, reinstall, repeat.

### Publish to a marketplace [#publish-to-a-marketplace]

`extension-toolkit publish` merges an extension's `.spirit/extension.json` into a marketplace's `.spirit/marketplace.json` and copies the content the entry's source needs:

```bash
npx @spiritagent/extension-toolkit publish ./my-extension ./my-marketplace
```

* The first argument is the extension directory; the second is the marketplace root — any local directory with a `.spirit/marketplace.json`: a template-repository clone, the Personal marketplace at `{spiritDataDir}/marketplaces/personal`, and so on.
* `--source local` (default): copy the full package into the marketplace's `extensions/<name>/` — including `.spirit/extension.json`, excluding `node_modules` and `.git` — and point the entry's `source` at it.
* `--source npm`: pin `name@version` from the extension's `package.json` as the entry's npm `source`; only the declared icon is copied into `extensions/<name>/` (marketplaces self-host icons). Publishing the npm package itself stays with your npm CLI.
* `--dry-run`: print the entry and the planned actions without writing anything.
* Merge rules: a same-name entry is replaced in place, otherwise the entry is appended. New entries and `version` changes fall back to `reviewStatus: "unverified"`; republishing the same version keeps it. Curation fields such as `featured` always keep the marketplace's values.
* The same validation as `check` runs first; on failure nothing is written.
