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 (SKILL.md folders), MCP (external servers), and Hooks (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
Scaffold a new extension with the create wizard:
npm create @spiritagent/extensionThe 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:
npx @spiritagent/extension-toolkit check ./my-extensionThe 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
| 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
| 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
| 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
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
Requires cli-ui. hooks is an object with path, not an inline array:
{
"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.
{
"hooks": [
{
"slot": "input.frame",
"variant": "accented",
"tokens": { "border": "accent" },
"prefix": "[",
"suffix": "]"
}
]
}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
| 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
| 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
main is loaded with dynamic import. Export one of:
export function activate(ctx) { ... }export default function activate(ctx) { ... }export default { activate }
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 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
| 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
| 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 |
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
Pack an extension directory into the shareable ZIP format (content plus .spirit/extension.json):
npx @spiritagent/extension-toolkit pack ./my-extensionThe 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.
To list the extension in a marketplace instead, see Publish to 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.
Use this 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:
- Scaffold it with
npm create @spiritagent/extension. - Run
npx @spiritagent/extension-toolkit publish ./my-extension /path/to/your-marketplace-clone— it copies the extension intoextensions/and merges the entry into.spirit/marketplace.json. See Publish to a marketplace. - Push — users add the marketplace with its Git URL.
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
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:
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'sextensions/<name>/— including.spirit/extension.json, excludingnode_modulesand.git— and point the entry'ssourceat it.--source npm: pinname@versionfrom the extension'spackage.jsonas the entry's npmsource; only the declared icon is copied intoextensions/<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
versionchanges fall back toreviewStatus: "unverified"; republishing the same version keeps it. Curation fields such asfeaturedalways keep the marketplace's values. - The same validation as
checkruns first; on failure nothing is written.