Neo Plugin Reference
1. Introduction
A Neo plugin is a self-contained directory that extends Neo with new capabilities. Each plugin carries a plugin.json manifest that declares its identity and lists what it contributes to Neo's subsystems. Plugins are loaded once at startup; their contributions are merged into the live registries so that skills, slash commands, hooks, output styles, agents, and MCP servers become available in the same session without manual wiring.
Plugins support six contribution surfaces: skills (sub-agent system-prompt bundles), commands (slash-command markdown files), hooks (lifecycle event handlers), mcpServers (MCP server configurations), output-styles (response formatting styles), and agents (sub-agent definitions). A plugin may contribute to any combination of these surfaces from a single directory.
Plugins discovered at Neo startup load through runPluginBootstrap; plugins enabled mid-session via /plugins enable dispatch in-process through the same primitives (Wave 5 Phase 5). /plugins disable synchronously tears down every contribution surface. No restart required for either transition. This matches Claude Code's cache-invalidation posture; see architecture.md §11.6 for the per-surface teardown details.
The canonical plugin example is templates/plugins/notion/. All claims in this document can be verified against that directory and the source files cited inline.
2. Installation
First enable — the approval prompt (Wave 5 Phase 6)
Enabling a plugin (whether explicitly via /plugins enable <name> or implicitly via /plugins install <name>) runs an approval gate that lists every surface the plugin contributes and asks for [y/n]:
$ neo /plugins install daily-digest
Plugin "daily-digest" v0.1.0 contributes:
- 1 skill dir(s)
- 1 workflow definition file(s)
Approve and enable? [y/N]: y
Installed plugin "daily-digest" to /home/user/.neo/plugins/daily-digest.
Enabled "daily-digest" (trust hash cfaf40e0... saved).For plugins that contribute only output-styles (no skills, MCP, hooks, commands, agents, or workflows — the migrated Explanatory style is the canonical example), the summary appends a one-line footer to lower the friction for cosmetic-only plugins:
$ neo /plugins install explanatory-style
Plugin "explanatory-style" v0.1.0 contributes:
- output styles
Safe to approve — adds 1 output style and nothing else.
Approve and enable? [y/N]: yThe trust record (manifest digest + timestamp + version) is written atomically to ~/.neo/config/plugin-trust.json. Subsequent enables compare against the digest — if the on-disk manifest is unchanged, there is no re-prompt; if it changed (a /plugins reinstall added new surfaces, an upstream update grew the contribution list), the prompt fires again.
Bypass in non-interactive contexts: pass --trust to skip the prompt and record trust silently. Per-plugin, per invocation — no global "trust everything" mode.
$ neo /plugins install daily-digest --trust
✓ Enabled "daily-digest" (trusted via --trust flag).Grandfathering pre-Phase-6 plugins: if you upgrade Neo and find your previously-enabled plugins now warn plugin enabled but not trusted at startup, run /plugins enable --accept-existing once to bulk-trust the current set with their on-disk digests.
For the full trust model (digest recipe, divergence from CC, file layout), see architecture.md §11.5.
/plugins install <name>
Copies a bundled template from templates/plugins/<name>/ to ~/.neo/plugins/<name>/, runs the approval prompt, and (on acceptance) adds the name to enabledPlugins in ~/.neo/settings.json plus dispatches the plugin's contributions in-process (Wave 5 Phase 5). Plugin commands, skills, agents, hooks, workflows, MCP servers, and output styles appear immediately — no restart required.
Discovery: resolveBundledTemplates() reads import.meta.url to locate the repo root, then walks templates/plugins/*/plugin.json. Each entry is validated with PluginManifestSchema.safeParse. Templates with invalid manifests are skipped and reported as warnings, not errors (src/cli/commands/plugins.ts:168-228).
Collision behavior: if ~/.neo/plugins/<name>/ already exists, the install fails with a message directing the user to re-run with --force. With --force, the existing directory is removed before the copy (src/cli/commands/plugins.ts:270-286).
enabledPlugins deduplication: install appends the name only if it is not already present (src/cli/commands/plugins.ts:289-294).
/plugins uninstall <name>
Removes ~/.neo/plugins/<name>/, removes the name from enabledPlugins, and clears the Phase 6 trust entry via forgetTrust(name). Idempotent — succeeds even if the directory or the enabledPlugins entry is already absent.
Preserving user state (Wave 5 Phase 7.5): if the plugin's manifest declares persistentData paths, those directories survive the uninstall:
{
"name": "my-plugin",
"persistentData": ["data", "credentials.json"]
}After /plugins uninstall my-plugin, the plugin's plugin.json is gone but data/ and credentials.json remain at their original locations under ~/.neo/plugins/my-plugin/. A reinstall (/plugins reinstall my-plugin) restores the rest while keeping those paths untouched.
Path-safety: entries are rejected (warn + drop, fail-open) when they are absolute paths or contain .. segments. Authors get a structured plugin_persistent_data_unsafe warn-log identifying the offending entry; the uninstall itself proceeds.
Reverse-dependency warning (warn-only per plans/wave5-decisions.md §Q9): if another loaded plugin lists the target in its dependencies array, the output appends " — warning: required by <other-plugin>". Uninstall is not blocked — the user keeps agency.
/plugins reinstall <name>
Replays the original install source (bundled template or --from <owner>/<repo>[/<subdir>]) while preserving any manifest.persistentData paths across the rebuild. The original source is recovered from a .install-source.json marker written at install time; pre-Phase-7.5 installs lack the marker and reinstall errors actionably with "Cannot determine install source for '<name>'. Remove + reinstall manually." Trust is implicitly re-granted (reinstall is an explicit user choice) so the approval prompt is skipped.
/plugins reinstall my-plugin
# → Reinstalled "my-plugin" from source { kind: "bundled", ref: "my-plugin" }. Preserved 1 declared path(s): data./plugins list-available
Prints all bundled templates discovered via resolveBundledTemplates(). Shows name, version, and description for each valid template (src/cli/commands/plugins.ts:345-371).
Manual enable/disable
To enable a plugin that is already installed (directory exists under ~/.neo/plugins/) but not active, add its name to the enabledPlugins array in ~/.neo/settings.json:
{
"enabledPlugins": ["notion", "my-plugin"]
}/plugins enable <name> runs the approval gate described above before mutating settings (src/cli/commands/plugins.ts:enablePluginWithApproval). /plugins disable <name> removes the name; trust state is preserved so re-enabling at the same digest skips the prompt. Both take effect immediately — Wave 5 Phase 5 ships in-process dispatch across all 7 contribution surfaces (skills, MCP servers, output styles, slash commands, hooks, agents, workflows), so plugin contributions appear or disappear without restarting Neo. See architecture.md §11.6 for the tiered teardown details.
NEO_HOME override
The plugins directory is ~/.neo/plugins/ by default. Set NEO_HOME to redirect the entire ~/.neo/ tree, including plugins:
NEO_HOME=/tmp/test-neo neogetPluginsDir() returns join(getNeoDir(), 'plugins') where getNeoDir() reads process.env['NEO_HOME'] ?? join(homedir(), '.neo') (src/runtime/config-loader.ts:36-38, src/runtime/config-loader.ts:128-130).
First migration: the explanatory-style plugin (Wave 5 Phase 7)
In Phase 7 the Explanatory output-style — previously shipped inline in src/output-styles/built-ins.ts — became Neo's first plugin-as-source migration. The text didn't change; what changed is the delivery path.
$ neo /plugins list-available
(includes: explanatory-style — Adds the Explanatory output style …)
$ neo /plugins install explanatory-style --trust
✓ Enabled "explanatory-style" (trusted via --trust flag).
$ neo /config
(under "Available output styles": Explanatory [plugin])After install, setting selectedOutputStyle: "Explanatory" in ~/.neo/settings.json resolves to the plugin-tier style exactly as it used to resolve to the built-in. Source priority is preserved: a ~/.neo/output-styles/Explanatory.md of yours overrides the plugin; a project-level .neo/output-styles/Explanatory.md overrides everything.
If you have selectedOutputStyle: "Explanatory" but the plugin is not installed, resolveOutputStyle emits a structured warn-log pointing at /plugins install explanatory-style and falls back to the default (no crash). The migration is the canonical example for "an in-source built-in becomes a plugin while keeping its public name." See docs/internal/plugins/architecture.md ("Built-in features as plugins") for the design rationale and what it leaves out.
3. plugin.json Manifest Schema
Every field below comes from PluginManifestSchema at src/plugins/types.ts:142-173. Unknown top-level fields are silently stripped (Zod default strip mode) — forward-compatibility keys are safe to include.
| Field | Type | Required | Description |
|---|---|---|---|
name | string (kebab-case) | yes | Plugin identity. Must match /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/. Used as directory name, slash-command namespace prefix, and registry key. |
version | string (semver) | no | Semver string (e.g. "1.2.3", "0.1.0-beta.1"). Shown in /plugins list. |
description | string | no | Short description shown in /plugins list and /plugins list-available. |
author | object | no | { name: string, email?: string, url?: string }. name is required if the field is present (src/plugins/types.ts:38-43). |
commands | string or string[] | no | Relative path(s) to a commands directory. Conventional: omit and put files in commands/. |
skills | string or string[] | no | Relative path(s) to extra skill directories beyond the conventional skills/ subdir. |
hooks | string or object | no | Relative path to a hooks.json file, or an inline { [eventName]: HookBackend[] } record. Conventional: omit and put hooks.json at the plugin root. |
mcpServers | string or object | no | Relative path to a .mcp.json file, or an inline { [serverName]: McpServerConfig } record. |
outputStyles | string or string[] | no | Relative path(s) to output-style files or directories beyond the conventional output-styles/ subdir. |
settings | object | no | Free-form key/value settings merged when the plugin loads. The loader prunes to allowlisted keys. |
Example:
{
"name": "notion",
"version": "0.1.0",
"description": "Notion workspace integration — pages, databases, blocks via the official Notion MCP server",
"author": { "name": "Neo Project" },
"mcpServers": {
"notion": {
"type": "http",
"url": "https://mcp.notion.com/mcp",
"headers": {
"Authorization": "Bearer ${NOTION_API_KEY}",
"Notion-Version": "2022-06-28"
}
}
}
}4. Conventional Directory Layout
The loader (src/plugins/loader.ts:119-183) walks these paths automatically — no manifest entries required for any of them.
<plugin-root>/
├── plugin.json # required — manifest
├── skills/
│ └── <skill-name>/
│ └── SKILL.md # auto-discovered; each subdir containing SKILL.md is a skill
├── commands/
│ └── <command>.md # auto-discovered .md files become slash commands
├── hooks.json # inline hooks config (JSON)
├── output-styles/ # output style files (whole dir registered)
└── .mcp.json # MCP server configs (alternative to inline mcpServers)Discovery detail:
skills/—findSkillDirsIn(join(pluginDir, 'skills'))returns immediate subdirs that contain aSKILL.mdfile (src/plugins/loader.ts:52-63). Subdirs withoutSKILL.mdare ignored.commands/—join(pluginDir, 'commands')is used ascommandsPathif the directory exists (src/plugins/loader.ts:127-131).hooks.json— read and JSON-parsed ifjoin(pluginDir, 'hooks.json')exists (src/plugins/loader.ts:133-139).output-styles/— the whole directory is registered if it exists (src/plugins/loader.ts:141-145).
Manifest overrides: if a conventional path is absent, the manifest field is used as fallback. For example, if commands/ does not exist, the first path in manifest.commands is used as commandsPath (src/plugins/loader.ts:163-172). Conventional paths always take priority over manifest entries.
5. Dispatch Lifecycle
runPluginBootstrap (src/plugins/bootstrap.ts:187-259) runs once at EmbeddedNeo startup before the agent loop begins. It never throws — failures per plugin are collected and returned.
Steps per plugin:
- Locate —
findPluginDir(pluginName, pluginSearchDirs)walkspluginSearchDirsin order; first matching directory wins (src/plugins/bootstrap.ts:97-108). Missing plugin →PluginNotFoundErrorcollected, plugin skipped. - Load —
loadPluginFromDirectory(pluginDir, 'user')reads and validatesplugin.json, walks conventional subdirs, resolves manifest extras, returnsLoadedPlugin(src/plugins/loader.ts:84-224). Validation failure → error collected, plugin skipped. - Register —
registry.register(plugin)adds the plugin to thePluginRegistry. Duplicatename→PluginDuplicateNameErrorcollected, plugin skipped. - Dispatch —
dispatchContributions(loaded, deps)routes each contribution surface (src/plugins/bootstrap.ts:117-181):
| Surface | Registry call |
|---|---|
skillsPaths (each) | skillsLoader.addPluginSkillDir(name, skillDir) |
mcpServers (each entry) | mcpServerRegistry.addServer(serverName, config) |
outputStylesPath | outputStylesLoader.addPluginOutputStyleDir(name, path) |
commandsPath | loadPluginCommands(name, path) then slashCommandRegistry.registerPluginCommand(name, cmd) per command |
hooksConfig | dispatchPluginHooks(name, hooksConfig, hookRegistry, logger) |
agentsPaths (each) | agentDefinitionsLoader(dir) or parseAgentDefinitionFile(file) → collected and deduplicated cross-plugin in runPluginBootstrap (src/plugins/bootstrap.ts:191-215) |
Fail-open: each contribution dispatch is wrapped in its own try/catch. A single subsystem rejecting a contribution (e.g. MCP server name collision) collects an error but does not abort the rest of the dispatch or the rest of the plugin list (src/plugins/bootstrap.ts:120-180).
6. Slash Commands Inside a Plugin
Frontmatter schema
Each .md file in the commands/ directory becomes a slash command. The YAML frontmatter block (between --- fences) is parsed against CommandFrontmatterSchema (src/plugins/command-loader.ts:35-41):
| Field | Type | Notes |
|---|---|---|
name | string (optional) | Override the command name. Defaults to the filename without .md. |
description | string (optional) | Short description. Defaults to the first non-empty body line. |
usage | string (optional) | Usage string shown in help. Defaults to /<name> [args]. |
aliases | string[] (optional) | Additional names that resolve to this command. |
argument-hint | string (optional) | Hint shown in the input when the command is selected. |
Body interpolation
The body is a prompt template (src/plugins/command-loader.ts:152-161):
$ARGUMENTS→ all arguments joined by space (context.args.join(' '))$1through$9→ positional arguments (empty string if not provided)- Positional replacements happen before
$ARGUMENTSto avoid$1matching inside$10+
Execution semantics
The run() function returns { output: '', sendAsUserMessage: <interpolated body> }. The router sends the interpolated body as a user message to the LLM (SlashCommandResult.sendAsUserMessage). This is prompt-template semantics: the command body IS the prompt, not code that executes.
Namespacing
Plugin commands are namespaced plugin:<plugin-name>:<command> in the registry. Users can type the bare name (/search) if unambiguous, or the full namespace (/plugin:notion:search) if multiple plugins provide the same name.
Worked example
---
name: search
description: Search the codebase for a pattern
usage: /search <pattern>
argument-hint: pattern to search for
---
Search the codebase for $ARGUMENTS. Show matching files and relevant context.Placed at commands/search.md, this creates /search (or /plugin:my-plugin:search). Running /search TODO sends "Search the codebase for TODO. Show matching files and relevant context." as the user message.
7. MCP Servers Inside a Plugin
Config shape
mcpServers is a { [serverName]: McpServerConfig } record. The full McpServerConfig shape is defined in src/mcp/types.ts and supports stdio (command/args/env) and HTTP (type: 'http', url, headers) variants.
Environment variable references
${ENV_VAR} placeholders in headers, args, and env values are preserved verbatim at manifest load time (src/plugins/loader.ts:185-195). Interpolation against process.env happens later during MCP server startup, not during plugin loading. This is a contract — confirmed by tests/runtime/templates-notion-plugin.test.ts:68-78:
expect(headers?.['Authorization']).toBe('Bearer ${NOTION_API_KEY}');Do not expect environment variables to be resolved when you call loadPluginFromDirectory. They will be substituted by the MCP client at connection time.
Notion example
{
"mcpServers": {
"notion": {
"type": "http",
"url": "https://mcp.notion.com/mcp",
"headers": {
"Authorization": "Bearer ${NOTION_API_KEY}",
"Notion-Version": "2022-06-28"
}
}
}
}At bootstrap, mcpServerRegistry.addServer('notion', config) is called with this config verbatim (src/plugins/bootstrap.ts:133-140). The ${NOTION_API_KEY} placeholder is resolved when the MCP client opens the HTTP connection.
8. Hooks Inside a Plugin
Config shape
hooksConfig (from hooks.json or inline hooks field) must be a plain object:
{
"<eventName>": [<HookBackend>, ...]
}eventName must be one of the 32 values in HOOK_EVENT_NAMES (src/hooks/types.ts:52-86). Examples: "PreToolUse", "SessionStart", "PostToolUse".
Valid backend types
Backends validate against HookBackendSchema — a discriminated union on type (src/hooks/types.ts:159-165):
type | Required fields | Notes |
|---|---|---|
command | command: string | Shell command run as a subprocess |
prompt | prompt: string | Prompt sent to the LLM |
http | url: string | HTTP POST to an endpoint |
agent | prompt: string | Prompt run as a sub-agent |
skill | skill: string | Named skill invoked as a sub-agent (Neo-native) |
All backends accept optional timeout (ms), statusMessage, and once fields.
Fail-open behavior
dispatchPluginHooks (src/plugins/hook-validator.ts:50-113) validates each entry individually:
- Unknown
eventName→ warn-log, skip that entire key - Non-array value → warn-log, skip that key
- Individual entry fails
HookBackendSchema.safeParse→ warn-log, skip that entry - Valid entries always register, regardless of invalid neighbors
The function never throws (src/plugins/hook-validator.ts:50-54).
Example
{
"SessionStart": [
{ "type": "skill", "skill": "morning-brief" }
],
"PostCompact": [
{ "type": "command", "command": "echo 'compaction done'" }
]
}9. Skills Inside a Plugin
Layout
Skills live in subdirectories of the plugin's skills/ directory. Each subdir containing a SKILL.md file is discovered as a skill:
<plugin-root>/skills/
└── <skill-name>/
└── SKILL.mdfindSkillDirsIn (src/plugins/loader.ts:52-63) reads the skills/ directory and returns absolute paths to subdirs that contain SKILL.md. Subdirs without SKILL.md are ignored.
Frontmatter contract
SKILL.md files use YAML frontmatter validated against SkillFrontmatterSchema (src/skills/types.ts:53-66):
| Field | Notes |
|---|---|
name | Display name override. Defaults to directory name. |
description | Short description surfaced to the skill picker. |
when_to_use | Guidance for the LLM on when to invoke this skill. |
model | LLM model override for the sub-agent. |
allowed-tools | Array of tool names the sub-agent may use. |
hooks | Per-event hook configs (same backend types as the hooks surface). |
Unknown frontmatter keys are silently stripped.
Registration
addPluginSkillDir(pluginName, path) (src/skills/loader.ts:316-318) registers the plugin's skills/ directory into the module-level _pluginSkillDirs list. The next loadSkills() call picks up the registered dirs and merges plugin skills at priority plugin (between user and project). Priority order: built-in < user < plugin < project — last wins on name collision.
10. Worked End-to-End Example: Notion Plugin
Directory tree
templates/plugins/notion/
├── plugin.json
├── README.md
└── skills/
└── connect-notion/
└── SKILL.mdplugin.json
{
"name": "notion",
"version": "0.1.0",
"description": "Notion workspace integration — pages, databases, blocks via the official Notion MCP server",
"author": { "name": "Neo Project" },
"mcpServers": {
"notion": {
"type": "http",
"url": "https://mcp.notion.com/mcp",
"headers": {
"Authorization": "Bearer ${NOTION_API_KEY}",
"Notion-Version": "2022-06-28"
}
}
}
}No commands, hooks, or outputStyles fields — the plugin contributes an MCP server and one skill via the conventional skills/ directory.
Install walkthrough
/plugins install notionresolveBundledTemplates()findstemplates/plugins/notion/plugin.json, validates it (src/cli/commands/plugins.ts:168-228).- Copies
templates/plugins/notion/to~/.neo/plugins/notion/(src/cli/commands/plugins.ts:286). - Appends
"notion"toenabledPluginsin~/.neo/settings.json(src/cli/commands/plugins.ts:289-294). - Dispatches the plugin's contributions in-process (Wave 5 Phase 5) — Notion's MCP server connects, its skill registers, and its slash commands appear in
/plugins listimmediately.
Bootstrap walkthrough
On the next Neo startup, runPluginBootstrap processes "notion" from enabledPlugins:
findPluginDir('notion', ['~/.neo/plugins'])→~/.neo/plugins/notion(src/plugins/bootstrap.ts:97-108).loadPluginFromDirectory('~/.neo/plugins/notion', 'user')(src/plugins/bootstrap.ts:217):- Reads and validates
plugin.json(src/plugins/loader.ts:91-117). - Checks
skills/→ findsskills/connect-notion/SKILL.md→skillsPaths = ['.../skills/connect-notion'](src/plugins/loader.ts:121-125). - No
commands/, nohooks.json, nooutput-styles/→ those fields areundefined. - Parses inline
mcpServers→mcpServers = { notion: { type: 'http', url: '...', headers: {...} } }with${NOTION_API_KEY}verbatim (src/plugins/loader.ts:185-195).
- Reads and validates
registry.register(plugin)— plugin is now queryable via/plugins status notion.dispatchContributionsroutes:addPluginSkillDir('notion', '.../skills/connect-notion')— skill registered; "help me connect Notion" will invoke it.mcpServerRegistry.addServer('notion', { type: 'http', ... })— MCP server available; Notion tools appear in the session.
Adding a slash command (future)
Round 7.B will add templates/plugins/calendar/commands/auth.md. The pattern: create commands/auth.md in the plugin directory with YAML frontmatter and a body template. After reinstalling and restarting, /auth (or /plugin:calendar:auth) invokes the command. No changes to plugin.json are needed when using the conventional commands/ directory.
11. Restart-to-Reload Constraint
Plugins load during runPluginBootstrap, which runs once at EmbeddedNeo startup (src/plugins/bootstrap.ts:187). Installing or enabling a plugin via /plugins install or /plugins enable writes to enabledPlugins immediately but the plugin does not become active until the next Neo restart.
This matches Claude Code's behavior. Live hot-reload is explicitly out of scope for v1: the registry mutation paths (skills cache, MCP registry, slash-command registry, hook registry) are not designed for concurrent modification after startup, and the correctness guarantees of restart-once are simpler to reason about.
Future direction: a hot-reload path would require cache invalidation across all six registries (skills, MCP, commands, hooks, output-styles, agents), a "drain and replace" strategy for active MCP connections, and disambiguation of in-flight hook dispatches. File an issue rather than attempting this in the plugin loader.
12. Troubleshooting
Plugin not loading at startup
- Check
enabledPluginsin~/.neo/settings.jsoncontains the exact plugin name (case-sensitive). - Check
~/.neo/plugins/<name>/plugin.jsonexists and is valid JSON. - Validate the manifest: run
node -e "const s = require('./plugin.json'); console.log(s)"in the plugin directory, then check that all required fields pass thePluginManifestSchemarules (name is kebab-case, version is semver if present, author.name is present if author is present). - Check startup logs: bootstrap logs
[plugins] plugin not foundor[plugins] failed to load pluginwith details atwarnlevel. Log location is set bylogLevelin settings and written to~/.neo/(seesrc/observability/logger.tsfor the sink configuration).
Slash command not found
- Check that
commands/directory exists inside the plugin root and contains at least one.mdfile. - Check that the
.mdfile has valid YAML frontmatter — a YAML parse error falls through to "no frontmatter" and uses the filename as the command name, which is usually fine, but a malformed---fence may cause the whole file to be skipped. - The command is registered as
/plugin:<name>:<cmd>and also as/<cmd>if unambiguous. If another plugin or built-in command has the same name, use the full namespace.
MCP server not connecting
MCP server connection is separate from plugin loading. The loader registers the server config (addServer); the MCP client establishes the connection asynchronously. If the connection fails:
- Check that environment variables referenced in
headersorenv(e.g.NOTION_API_KEY) are set in~/.neo/.envor the shell environment before Neo starts. - Run
/mcp statusto see the connection state for each server. - Check
/mcp status <server-name>for the specific error.
Manifest validation error
PluginManifestInvalidError carries a Zod issues array. Common causes:
namecontains spaces or uppercase letters — must be kebab-case:/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.versionis not semver — must matchmajor.minor.patchwith optional pre-release suffix.authoris present butauthor.nameis missing.author.emailis present but not a valid email address.
Plugin name collision
PluginDuplicateNameError is thrown when two loaded plugins share the same name field. Rename one or remove it from enabledPlugins.