Plugins
Experimental plugins in the format of Claude Code's plugins, with a Cogitator extension for code, isolation, permissions and Studio panels, loaded by generations and installed from marketplaces, npm and git.
Overview
A plugin bundles what an agent runtime can be extended with: tools, skills, subagents, commands, hooks, MCP servers, LLM backends, Studio panels and the settings they need. Cogitator reads plugins in the format of Claude Code: a folder with .claude-plugin/plugin.json (or none at all) and the standard skills/, commands/, agents/, hooks/hooks.json and .mcp.json. A plugin written for Claude Code loads here unchanged. A Cogitator-native manifest, .cogitator-plugin/plugin.json, extends it with code, Studio panels, a permissions manifest and engine constraints, and Claude Code never sees it.
Experimental. Every entry point carries the experimental_ prefix (experimental_plugins, cog.experimental_plugins, experimental_definePlugin, experimental_PluginInstaller, ...) and may change in a minor release. The full list is in docs/EXPERIMENTAL_API.md.
import { Cogitator, Agent } from '@cogitator-ai/core';
import { experimental_mcpConnector } from '@cogitator-ai/mcp';
const cog = new Cogitator({
llm: { defaultModel: 'ollama/qwen3:8b' },
experimental_plugins: {
dirs: ['./plugins/deploy-tools'],
mcp: experimental_mcpConnector(),
},
});
const agent = new Agent({ name: 'ops', instructions: 'You run deployments.' });
const result = await cog.run(agent, { input: '/deploy-tools:status production' });
for (const plugin of cog.experimental_plugins.list()) {
console.log(plugin.id, plugin.status, plugin.contributions);
}Without experimental_plugins nothing changes: the runtime has only the core's own plugin, which registers the built-in LLM providers.
Where plugins come from
| Source | Id | Runs |
|---|---|---|
| The core's own plugin (built-in providers) | cogitator@builtin | In the host process, always on |
experimental_plugins.plugins, definitions in code | name@code | In the host process |
experimental_plugins.dirs, folders loaded in place (Claude Code's --plugin-dir) | name@inline | Declarative parts in the host, code in its own process |
Installed with cogitator plugin install (installed, on by default) | name@marketplace, name@npm, name@git, name@github | Declarative parts in the host, code in its own process |
When two enabled plugins share a name, the one managed settings enable wins, else code wins over folders and folders over installed plugins. The one left out is disabled with the reason in its error. A disabled plugin shadows nothing, so turning "review@inline": false lets the installed review@team load.
Layout and manifest
A plugin folder in Claude Code's standard layout:
deploy-tools/
├── .claude-plugin/plugin.json # optional, name defaults to the folder
├── .cogitator-plugin/plugin.json # optional, Cogitator's extension
├── skills/deploy/SKILL.md # skill deploy-tools:deploy
├── commands/status.md # command /deploy-tools:status
├── commands/db/migrate.md # command /deploy-tools:db:migrate
├── agents/review/security.md # subagent deploy-tools:review:<name>
├── hooks/hooks.json # { "hooks": { "PreToolUse": [...] } }
└── .mcp.json # server plugin:deploy-tools:<server>Cogitator reads every field of Claude Code's manifest that applies at run time: name (required, no spaces, @, : or path separators), displayName, version, description, author, homepage, repository, license, keywords, defaultEnabled, dependencies, userConfig, skills, commands (paths or a map of source/content entries), agents, hooks (files or inline) and mcpServers (files or inline). The same rules hold as in Claude Code:
- component paths start with
./, must exist and stay inside the plugin, links followed (path escapes plugin directory,path not found) commandsandagentsreplace their default folder,skillsadds toskills/,hooksandmcpServersmerge withhooks/hooks.jsonand.mcp.json- an unknown top-level field is stripped with a warning, an unknown key inside a
userConfigoption fails the plugin - a hooks file needs the
{ "hooks": { ... } }wrapper, an inlinehooksvalue does not.
.cogitator-plugin/plugin.json takes the same fields, which override Claude Code's field by field, plus Cogitator's own:
| Field | What it does |
|---|---|
main | An ES module whose default export is a plugin definition (see Plugins in code). It runs in a process of its own |
permissions | { allow, deny } permission rules of what the plugin may do beyond its folder, see Isolation |
ui.panels | Studio panels: { id, title, entry: './ui/panel.html', icon?, description? } |
engines.cogitator | A semver range of Cogitator versions the plugin works with |
Claude Code features without a counterpart here (lspServers, outputStyles, themes, monitors, workflows, channels, bin/, the main thread agent of settings.json, MCP bundles .mcpb) are reported as warnings and skipped.
What a plugin adds
| Component | Name in the runtime | Notes |
|---|---|---|
| Tools | their own name | Every agent of the runtime gets them, after its own tools and cog.tools |
| Skills | plugin:skill | Every agent may activate them with use_skill |
| Subagents | plugin:folder:name | permissionMode, hooks and mcpServers of a plugin agent are ignored, as in Claude Code |
| Commands | /plugin:name, /plugin:folder:name | Expanded before the run, as commands |
| Hooks | in the plugin's own hook layer | The managed disableAllHooks and allowManagedHooksOnly still apply |
| MCP servers | plugin:<plugin>:<server>, tools mcp__plugin_<plugin>_<server>__<tool> | Connected by experimental_plugins.mcp, without it skipped with a diagnostic |
| LLM backends | provider/model | The LLMPlugin of before is one kind of contribution |
| Middleware | Plugins in the host's process only | |
| Studio panels | See Studio panels | |
| Routines | plugin:id, triggers plugin:id.<trigger> | Fired by the runtime's trigger manager, a run holds a lease on the plugin |
A run takes what the live plugins add when it starts, so a reload never changes a run that is going on.
Variables
The files of a plugin reference its paths and options as in Claude Code:
| Variable | Value |
|---|---|
${CLAUDE_PLUGIN_ROOT} (${COGITATOR_PLUGIN_ROOT}) | The folder of the loaded version |
${CLAUDE_PLUGIN_DATA} (${COGITATOR_PLUGIN_DATA}) | <plugins>/data/<id>-<hash>/, kept across updates |
${CLAUDE_PROJECT_DIR} | The project root (experimental_plugins.root, else the root of the settings, else the working directory) |
${user_config.KEY} | An option value |
The data folder is named after the id as Claude Code names it (every character but a letter, digit, _ or - turned into -) with a hash of the whole id after it, so ids that would otherwise share a folder, such as foo-bar@baz and foo@bar-baz, never do. It is created the first time the plugin uses it (a reference, the environment of a hook or MCP server, context.plugin.dataDir), and the core's own plugins have none. They are put into hook commands and arguments, MCP server configs, and the text of skills, commands and agents. A secret option goes into MCP configs and the arguments of an exec-form hook, while in text the model reads it becomes [sensitive option KEY], and a shell-form hook command that names an option is refused. Hook processes get CLAUDE_PLUGIN_ROOT, CLAUDE_PLUGIN_DATA and CLAUDE_PLUGIN_OPTION_<KEY> for every option.
Options and secrets
A plugin's settings are Claude Code's userConfig: each option has a type (string, number, boolean, directory, file), a title and a description, and may be required, have a default, fixed options, be multiple or sensitive.
- Plain values are read from
pluginConfigs["<id>"].optionsof the user's (or managed) settings,experimental_plugins.optionsin code, then the defaults. - A
sensitivevalue is a secret: it lives in the operating system's credential store, never in a settings file.experimental_keychainSecretStore()uses the system's own tools without a dependency:securityon macOS,secret-toolon Linux, the Credential Manager through PowerShell on Windows. The secret goes to the tool's standard input, never on a command line. - Without such a tool a secret cannot be saved or read and the call fails with
SECRET_STORE_UNAVAILABLE. Claude Code falls back to a plain~/.claude/.credentials.jsonthere, Cogitator does not. Passexperimental_plugins.secretswith a store of your own (orexperimental_memorySecretStore()) on a server.
cogitator plugin configure deploy-tools@team --set api_endpoint=https://deploy.example.com --secret api_tokenEnabling plugins
Whether a plugin loads follows enabledPlugins of the settings files, read where the permission settings are (.claude/ and .cogitator/ of the project, ~/.cogitator and ~/.claude of the user, the managed directories):
{ "enabledPlugins": { "deploy-tools@team": true, "noisy@team": false } }The highest layer that names a plugin decides: managed, then experimental_plugins.enabled in code, then the local, project and user files. A project's own files count only once the project is trusted (experimental_plugins.settings.trusted, or permissions.settings.trusted), as for hooks. A plugin no layer names follows its defaultEnabled (true by default), and a plugin an enabled plugin depends on is enabled with it unless a layer turns it off.
Plugins in code
A plugin in code registers what it adds through its PluginContext. The core's own plugin, the host's plugins and third-party plugins all use this one API, and every registration returns its disposer.
import { experimental_definePlugin } from '@cogitator-ai/core';
export default experimental_definePlugin({
name: 'weather',
version: '1.0.0',
permissions: { allow: ['WebFetch(domain:api.weather.example.com)'] },
activate(context) {
context.registerTool({
name: 'weather_now',
description: 'The weather in a city now',
parameters: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'] },
readOnly: true,
execute: async ({ city }) => {
const response = await context.fetch(
`https://api.weather.example.com/now?q=${String(city)}`
);
return response.text;
},
});
context.registerSkill({
name: 'forecast',
description: 'Explains a forecast',
instructions: '...',
});
const dispose = context.registerCommand('today', 'What is the weather in $ARGUMENTS today?');
context.onDispose(() => console.log('unloading'));
return () => dispose();
},
});PluginContext | |
|---|---|
plugin | { id, name, version, root, dataDir, isolated } |
options, secret(key) | Option values that are not secrets, and a secret from the store |
files, fetch, exec | What the plugin may do beyond its folder, checked against its permissions |
registerTool(tool) | A tool() or a spec with a JSON Schema parameters |
registerSkill, registerAgent, registerCommand, registerHooks, registerMcpServer | The components a folder plugin declares, in code |
registerLLMBackend(plugin) | An LLMPlugin: agents with models provider/model run on it |
registerMiddleware(middleware), registerPanel(panel) | Run middleware (host process only) and Studio panels |
registerRoutine(routine), registerTrigger(routineId, spec) | Routines named plugin:id and more triggers of them, with the permission Routine |
onDispose(dispose), signal, logger | Cleanup, an abort signal of the unload, a logger tagged with the plugin |
ToolRegistry.register() and registerLLMBackend() return their disposer too.
Lifecycle
Each load of a plugin is a generation. Its registrations and the disposers they return are kept in order, and unloading a generation runs them in reverse order, the disposer activate returned first. A disposer that throws does not stop the others, its failure is a diagnostic.
const results = await cog.experimental_plugins.reload('deploy-tools@team');
// [{ id: 'deploy-tools@team', ok: false, generation: 3, error: '...' }]reload(id) builds a new generation next to the live one (a new process for an isolated plugin) and swaps them only once the new one loaded in full, MCP servers connected and hooks checked. When it fails, the half-built generation is unloaded and the old one serves on: the result has ok: false, code: 'PLUGIN_RELOAD_FAILED' and the generation still serving, and a reload-failed event is emitted. An MCP server that does not start fails a reload this way (on a first load it is only a diagnostic), as does a secret the credential store cannot give, a registration of an isolated plugin the host refuses (a tool with a schema that is not an object, say, as a throwing registration fails a plugin in code), a new manifest whose dependencies cannot load, and a plugin.json saved half-written keeps the plugin's id and its live generation, along with the version list() shows, until a save that loads. A generation reads the skills of a plugin folder once, as it loads: their instructions and the files they list (up to 100 a skill, as files lists them) stay as they were for the runs that hold it, however the folder changes after. A run keeps the generations it started with: a reload during the run serves the new generation to later runs, while the run goes on with the old one (its tools, skills, subagents, middleware, hooks and the MCP servers its mcp_tool hooks call, its LLM providers and its process), which unloads once the last run that holds it ended. invokeTool() holds the generations the same way for its call. A command is expanded with the plugins as they are when the run starts. cog.experimental_plugins.lease() gives the same hold to code of your own that reads snapshot() across awaits. reload() without an id rebuilds every plugin and picks up plugins installed, removed, enabled or disabled since. watch: true reloads a folder plugin when its files change.
Plugins load in the order of their dependencies ("name", "name@marketplace", or { name, marketplace?, version? } with a semver range) and unload in the reverse order, a plugin before the plugins it depends on. A plugin whose dependency is missing, disabled, failed to load, installed at a version outside its range or blocked is blocked with PLUGIN_DEPENDENCY_UNMET, every plugin of a dependency cycle with PLUGIN_DEPENDENCY_CYCLE, and the others load anyway. As in Claude Code, a local copy (a plugin in code or a dirs folder) satisfies a dependency on its name whatever marketplace the dependency names, and its version is not checked against the range. The range of an installed dependency is checked against the version of its plugin.json (which a release tagged <name>--v<version> is expected to match), else the version it was installed under, such as an npm package's, as the installer checks it. On a reload a plugin whose new version needs a range the dependency's live generation is outside (its own reload failed, say) keeps its old generation, with PLUGIN_RELOAD_FAILED. A dependency resolves to the copy that loads: one shadowed by another plugin of its name stands for the one that loads instead, and of several plugins of a name the enabled one is taken. cog.experimental_plugins.list() gives each plugin's status (active, disabled, blocked, failed, unloaded), error, generation, contributions and the diagnostics of its latest load.
Isolation and permissions
A plugin with code from a folder (a main of an installed plugin or of a dirs folder) never runs in the host's process. It runs in a child process under Node's permission model:
- it reads only its own folder and writes only its data folder
- it cannot start processes, workers, native addons or WASI, or open the inspector
- the network is closed: on Node 25 and later by the permission model itself (no
--allow-net), and on every version by closingnet,tls,dgram,dns(its resolvers included),fetchand WebSocket before the plugin's code runs - its folder may hold no link that leads outside it, since the permission model follows links: such a plugin fails to start with
PLUGIN_INVALID, as does one whosemainis missing or outside its folder (on a reload the live generation goes on).
Everything else it asks the host for, through context.files, context.fetch and context.exec, and the host decides with the permission engine against the plugin's permissions, in dontAsk mode: what no allow rule covers is refused with PLUGIN_PERMISSION_DENIED, a deny rule refuses what an allow rule would let through, and experimental_plugins.permissions.deny refuses over every plugin.
{
"main": "./main.mjs",
"permissions": {
"allow": [
"Read(./docs/**)",
"Bash(git status)",
"WebFetch(domain:api.example.com)",
"Bash(${CLAUDE_PLUGIN_ROOT}/scripts/check.sh)"
],
"deny": ["Read(./docs/private/**)"]
}
}Paths in the rules start from the project root, ${CLAUDE_PLUGIN_ROOT} and ${CLAUDE_PLUGIN_DATA} are put in. The rule Routine lets a plugin register routines and triggers, which start runs on their own: without it registerRoutine and registerTrigger throw PLUGIN_PERMISSION_DENIED, isolated or not. For a plugin that declares permissions, every hook and MCP server it brings is checked against them too, and the ones its rules do not allow are left out: the commands of command hooks and stdio MCP servers (with their cwd), the URLs of http hooks and remote servers, and the server and tool of an mcp_tool hook (mcp__<server>__<tool>, or mcp__<server> for every tool of it). An http hook of such a plugin may put only the plugin's own variables in its headers, never the host's (allowedEnvVars). The plugin's own variables, CLAUDE_PLUGIN_OPTION_<KEY> included, reach the headers of its http hooks as they reach its command hooks. A prompt hook only asks the model about the event and needs no rule. Such a plugin's stdio MCP server may not set a variable that changes what a program loads or how a shell starts (NODE_OPTIONS, LD_PRELOAD, DYLD_*, PATH, BASH_ENV, PYTHONPATH, NPM_CONFIG_* and the like), since that would turn an allowed command into any code. A plugin with code that declares no permissions may register no hooks and no MCP servers from its code at all, and the host's deny rules apply to the hooks and MCP servers of every plugin, mcp_tool hooks included. Commands run through context.exec get only the host variables that say where programs are (PATH, HOME, LANG, TMPDIR and the like), never its secrets.
The permissions an installed plugin asks for are agreed to at install, and rules a later version adds load only once agreed to again. A version that drops its permissions block keeps running under the rules granted before, so leaving the block out never lifts the checks.
Plugins in the host's code get no privileges beyond that: their files, fetch and exec are checked against their permissions the same way (their own imports are the host's code). A plugin without permissions, such as one in Claude Code's format, keeps Claude Code's trust model for the hooks and MCP servers of its files: enabling it trusts them, they run as processes of the user, and only the host's deny rules refuse any. The OS-level sandbox for those processes is planned separately, and Node's permission model is a guard rail rather than a security boundary against hostile native code.
What these checks do not cover yet:
context.fileschecks the real path of a file, then opens it by its path, so a plugin that may write somewhere it can also read could swap a link in between. GrantEditrules only for folders a plugin does not also share with code you do not trust.- On macOS a secret is saved through
/usr/bin/security, which the keychain then trusts to read it without asking. Any process of the user can read it the same way, the hooks and MCP servers of other plugins included. Linux and Windows stores ask nothing either. Use a store of your own (experimental_plugins.secrets) where that matters.
Installing plugins
cogitator plugin marketplace add your-org/claude-plugins # owner/repo, git URL, https marketplace.json, ./path
cogitator plugin install formatter@your-org # from a marketplace
cogitator plugin install npm:@acme/review-kit@^2.0.0 # straight from npm
cogitator plugin install git+https://git.example.com/tools.git#v1.2.0
cogitator plugin list
cogitator plugin update formatter@your-org
cogitator plugin disable formatter@your-org --scope project
cogitator plugin uninstall formatter@your-org --prune
cogitator plugin validate ./my-plugin --strictMarketplaces are Claude Code's .claude-plugin/marketplace.json with the same plugin sources: a relative path inside the marketplace, github, url (git), git-subdir, npm, archive (zip over https with a sha256 pin) and command. Marketplaces declared in extraKnownMarketplaces of the settings are fetched when first used. Reserved and official marketplace names are refused as Claude Code refuses them.
Every install is pinned to exactly what it fetched, in <plugins>/installed_plugins.json (COGITATOR_PLUGINS_DIR, default ~/.cogitator/plugins):
- a git source: the full commit SHA
- an npm package: the exact version and its
sha512integrity (its name and version or dist-tag checked against npm's grammar before npm runs, and npm started without a shell: on Windows node runs npm'snpm-cli.js, fromnpm_execpathor next to node, and the install fails when there is none), checked against the tarball (against the registry'ssha1when it gives nosha512, and refused when it gives neither), fetched with your npm client and its.npmrc, without install scripts - an archive: its SHA-256.
The version a plugin is installed under is its manifest's version, else its entry's, else the source's (a 12-character commit or digest), and an update installs a new version only when that version changed, as in Claude Code. A version that cannot name a folder inside the plugin's own (., .., one starting with a dot) is refused with PLUGIN_INVALID. A dependency with a range installs at the highest <name>--v<version> tag that satisfies it, and an update of that dependency stays at the highest tag every installed plugin's range on it allows. An update installs the dependencies the new version adds before it switches to it. Installing a plugin that is already there as a dependency of another takes it as installed by you: uninstall --prune keeps it, and the options and secrets given are checked and saved. Updating a plugin keeps it at its version when the new one leaves a range an installed plugin puts on it, with a note that says which. Every version an install, a dependency install or an update would write is checked, before anything is written and whatever the source (a relative path, git, npm, an archive), against every range the installed plugins and the plugins being installed put on it, those still in the middle of their own install included. An install or update that fails removes the plugins it installed on the way. A dependency already installed at a version outside the range a new plugin (or a new version) needs is updated to the highest version every installed plugin's range allows, or the install fails with PLUGIN_DEPENDENCY_UNMET before anything is written.
Options and secrets given to install are checked against the plugin's userConfig before anything is fetched into place or written, and go to that plugin only, never to the dependencies it brings. configure and the other commands take a bare name when one installed plugin has it and refuse a name several marketplaces installed. enable and disable without --scope write to the settings file that decides now (local, then project, then user, as Claude Code looks), else the user's, and warn when a file of a higher layer still decides otherwise. Adding a marketplace fetches it into a staging folder first, so a marketplace whose name is already taken by another source never touches the files of the one added. Downloads go over https only, never to loopback, link-local or metadata addresses, and git and npm take http only to this machine. The headers of a marketplace or an archive go only to the origin of its URL: a redirect to another origin is followed without them. An IPv6 address is read in full before the check, so the IPv4 address it carries ([::ffff:127.0.0.1], NAT64, 6to4) is checked as that address, and unspecified, unique-local (fc00::/7), link-local and multicast addresses are refused too.
experimental_PluginInstaller gives the same operations in code.
Studio panels
A plugin adds pages to Studio: ui.panels in its manifest, or context.registerPanel({ id, title, html, handlers }) in code. Studio shows a Plugins page with every plugin, its status, contributions, permissions and diagnostics, with Reload and Enable or Disable, and opens a panel in a sandboxed frame whose page may load or fetch nothing from any address. The page can still navigate its own frame, which Studio keeps on its own address and answers by closing the panel, and some channels no browser policy covers stay open, so treat a panel as able to send out what its handlers return and never return secrets to it, see Plugins in Studio. The page asks its plugin through window.parent.postMessage({ type: 'cogitator:request', id, method, params }) and gets { type: 'cogitator:response', id, result } or { ..., error } back, method naming one of the panel's handlers.
Evals
cogitator plugin eval runs a plugin's eval suite in the format of claude plugin eval, with and without the plugin, see Plugin evals.
Differences from Claude Code
- Plugin components are namespaced the same way, but subagents named
plugin:namekeep their memory in the folderplugin.name. - MCP bundles (
.mcpb,.dxt), LSP servers, output styles, themes, monitors, workflows, channels andbin/are not loaded, and the main threadagentof a plugin'ssettings.jsondoes not apply. - Secrets never fall back to a plain file.
- A marketplace source of type
npmis not supported, as in Claude Code.
Error codes
| Code | When |
|---|---|
PLUGIN_NOT_FOUND | No such plugin, marketplace or panel |
PLUGIN_INVALID | A manifest that keeps the plugin from loading, an engines.cogitator range this version is not in |
PLUGIN_ACTIVATION_FAILED | The plugin's process did not start, did not activate in time or exited |
PLUGIN_DEPENDENCY_UNMET, PLUGIN_DEPENDENCY_CYCLE | See Lifecycle |
PLUGIN_PERMISSION_DENIED | A request its permissions do not allow, permissions not granted at install |
PLUGIN_RELOAD_FAILED | A reload failed and the previous generation goes on, in the reload result |
PLUGIN_INSTALL_FAILED | A fetch, a check of a pin or a write failed |
SECRET_STORE_UNAVAILABLE | No credential store of the system |
Commands
Message templates a run's input names with /name, from Markdown files in Claude Code's commands format or from code.
Triggers, Routines and the Inbox
Experimental routines that start agent or workflow sessions on their own, on a cron schedule, an API call, a signed webhook from GitHub, email or Slack, or a task an agent gave itself, exactly once per event, with the results in an inbox.