Agent Plugins
A conformant Agent Plugins v1 client — discovers plugin packages from agent-plugins.org, shows their Agent Skills to the model using progressive disclosure, and attaches their MCP servers as connections.
Overview
Agent Plugins is an open, vendor-neutral standard for packaging reusable agent components. A plugin is a directory with a plugin.json manifest and two portable component types:
- Skills —
skills/<name>/SKILL.md, conforming to the Agent Skills specification - MCP servers —
mcp.json, over stdio, Streamable HTTP, or the legacy HTTP+SSE transport
agentPlugins() discovers those packages and puts their skills in front of the model, following the progressive disclosure model the Agent Skills spec prescribes: skill descriptions are always in context, skill instructions load on demand, and bundled files load one at a time. The MCP servers reach the model as connections: pluginConnections() turns each one into an mcp() entry you attach to the harness or a turn.
- Default slot:
250(Slot.PROCEDURAL) — skills are procedural knowledge - Default scope:
thread— the plugin set is fixed, but which skills are active is conversation state - Budget:
{ min: 200, max: 4000 } - Placement:
'anchor'— the skill index is identical every turn, so it belongs in the cache-stable prefix
This layer ships in its own package, @noetic-tools/agent-plugins, not in @noetic-tools/core. Its MCP servers run through @noetic-tools/mcp and spawn subprocesses, and neither belongs in core's dependency graph.
Installation
bun add @noetic-tools/agent-pluginsUsage
import { agentPlugins, discoverPlugins, pluginConnections } from '@noetic-tools/agent-plugins';
import { AgentHarness } from '@noetic-tools/core';
const roots = ['/home/alex/.agents/plugins'];
const dataDir = '/home/alex/.agents/plugins-data';
// Each plugin MCP server becomes a connection named `<plugin>_<server>`.
const { connections } = await pluginConnections(await discoverPlugins(roots, dataDir));
const harness = new AgentHarness({
name: 'my-agent',
params: {},
// Context layers go under `contextLayers`, as a plain array.
contextLayers: [agentPlugins({ roots, dataDir })],
connections,
// The harness does not pick up OPENROUTER_API_KEY on its own — name the
// provider explicitly, or model calls fail at run time.
callModelDefaults: { provider: 'openrouter' },
});For a compile-time typed handle on ctx.context['agent-plugins'], wrap the same layer with context() and spread it into the harness:
import { agentPlugins } from '@noetic-tools/agent-plugins';
import { context, type InferContext } from '@noetic-tools/core';
const config = context([
agentPlugins({
roots: ['/home/alex/.agents/plugins'],
dataDir: '/home/alex/.agents/plugins-data',
}),
]);
type Plugins = InferContext<typeof config>;
// `config.layers` is a readonly tuple, so spread it for the harness:
const layers = [...config.layers];Each immediate child of a roots directory that contains a plugin.json is loaded. Children without one are ignored, so a plugins directory can hold other things.
Point roots at the directory that contains plugins, not at a plugin itself. Getting this wrong finds nothing; the layer reports a root-empty diagnostic saying so, and names the mistake when the directory has its own plugin.json.
Configuration
import type { BudgetConfig, ContextScope } from '@noetic-tools/types';
interface AgentPluginsConfig {
/** Directories to scan for plugin directories. */
roots: readonly string[];
/** Base directory for per-plugin PLUGIN_DATA (`<dataDir>/<plugin-name>`). */
dataDir: string;
slot?: number;
scope?: ContextScope;
budget?: BudgetConfig;
}Progressive disclosure
The layer implements the three tiers the Agent Skills spec describes.
Tier 1 — metadata. Every skill's name and description appear in recall output on every turn, at roughly 100 tokens per skill. This is what lets the model know a skill exists:
<agent_plugins>
<plugins>
- reports v1.2.0 — Reporting helpers
</plugins>
<skills>
Call loadSkill with a skill id to read its full instructions.
- reports/summarize: Summarizes reports. Use when the user asks for a summary.
- reports/chart: Charts data. Use when the user asks for a chart.
</skills>
<mcp_servers>
Each server is a connection. List its operations with <connection>_ops and run one with <connection>_call. When several connections share them, the tools are ops and call, with target naming the connection.
- reports/api: connection reports_api
</mcp_servers>
</agent_plugins>Tier 2 — instructions. Calling loadSkill returns the SKILL.md body and pins it into context for the rest of the thread, inside an <active_skills> block.
Tier 3 — resources. readSkillResource reads one bundled file at a time from scripts/, references/, or assets/.
Because only activation changes the block, the layer implements renderDelta. The hook republishes the block in full: the runtime publishes a delta under action="replace", so emitting only the new skill would tell the model that the index and every earlier activation had been superseded by a block containing neither. The benefit is cache stability rather than payload size — the anchored prefix stays byte-identical and the correction is appended.
When a provider never reports a cache hit, the runtime probes it, and each probe re-anchors. A fresh epoch has nothing to supersede, so a change landing on a re-anchor turn is folded into the new pins rather than published as a delta — the model still sees it, in the anchor band. Once the provider is marked cache-blind the epoch settles and deltas publish normally. Early turns against a non-caching provider can look as though renderDelta never fires.
Budget behavior
Under pressure the layer sheds the oldest activated skill body first, then the next, and only trims the index as a last resort — the index is what makes the model aware a skill exists at all. A zero budget fails open rather than deleting the block.
Exposed functions
These become LLM tools automatically, and are callable from code steps via ctx.context['agent-plugins'].
| Function | Purpose |
|---|---|
loadSkill({ skill }) | Returns a skill's instructions and its bundled-resource list, and activates it |
readSkillResource({ skill, path }) | Reads one bundled file, contained to the skill directory |
loadSkill accepts either the qualified <plugin>/<skill> id or a bare skill name. A bare name resolves when exactly one plugin provides it; when two do, the call reports the candidates instead of guessing.
The layer has no MCP tool of its own. A plugin's tools are the tools of its connection.
Exposed data
| Key | Contents |
|---|---|
plugins | Every loaded plugin, with its manifest, root, and data directory |
skills | Every discovered skill, with frontmatter and body |
mcpServers | Each plugin server (<plugin>/<server>) and its connection name |
diagnostics | Everything that was skipped or rejected, and why |
activeSkills | Qualified ids activated in this thread |
Diagnostics
The spec requires that failures be isolated, not silent. A broken skill never stops its siblings loading; a server that will not start is an unavailable connection and never stops the plugin's skills, or any other connection, working. Every skip produces a diagnostic naming the specification section it enforces:
import { agentPlugins } from '@noetic-tools/agent-plugins';
const layer = agentPlugins({
roots: ['/home/alex/.agents/plugins'],
dataDir: '/home/alex/.agents/plugins-data',
});
// after the harness has run init
for (const d of layer.readDiagnostics()) {
console.warn(`[${d.code}] ${d.pluginName ?? d.pluginDir}: ${d.detail}`);
}Diagnostics are also emitted onto the execution trace as agent-plugins.diagnostic events, so they show up in observability without anyone having to remember to read them.
| Code | Meaning |
|---|---|
plugin-rejected | The plugin is unusable; none of its components load |
unknown-manifest-field | An unknown plugin.json field was reported and ignored |
invalid-extensions | extensions was not an object; the field was ignored |
component-type-invalid | A fixed location was present but the wrong kind |
skill-skipped | A skill does not conform to the Agent Skills spec |
mcp-disabled | mcp.json is unusable; MCP is off for this plugin only |
mcp-server-invalid | One server entry is invalid; siblings still load |
mcp-transport-unsupported | The host has not enabled that entry's transport |
mcp-connect-failed | The server's PLUGIN_DATA directory could not be created, so it was not attached |
skill-warning | A skill loaded, but carries a frontmatter key the spec does not define |
root-unreadable | A configured scan root could not be read |
root-empty | A scan root held no plugin directories — usually a misconfigured path |
Security
The layer enforces the specification's containment rules, which matter because a plugin is third-party code on disk:
- Every package path is resolved through
realpathbefore use. A symlink may point outside the plugin directory, so a lexical check would letskills/xlook contained while resolving to/etc. Paths that cannot be resolved are treated as escapes — the check fails closed. readSkillResourceis contained to the skill directory, which is stricter than the spec requires and stops one skill reading another's files.- MCP
commandvalues must be a single executable token, never a shell command string, so no user-authored string is ever parsed or escaped as shell syntax. - Plugin subprocesses receive a narrow environment allowlist, not the agent's environment. Ambient secrets — API keys and the like — are not passed through.
PLUGIN_ROOTandPLUGIN_DATAare applied last, so a plugin cannot spoof them. Note that the MCP SDK's stdio transport also merges its own default environment (HOME,SHELL,USER, …) into the child and offers no way to suppress that, so the allowlist bounds what this package forwards, not the child's total environment. - A plugin's configured
Authorization,mcp-session-id, and other client-owned headers are dropped rather than sent, per the specification's precedence rule — otherwise a plugin could replace the agent's own credentials. Each drop is reported as a diagnostic. - Plugin-controlled text (descriptions, skill bodies) cannot forge this layer's own context tags, so a plugin cannot close the block and append text the model would read as a system instruction.
- Configured
envvalues and MCP headers are visible package data. The specification is explicit that they are not a secret mechanism; do not put credentials in either.
Plugin servers as connections
pluginConnections(discovered) takes what discoverPlugins() found and returns one mcp() connection entry per server, plus a diagnostic for each server it skipped. Attach the entries to the harness, for its whole life, or to one turn:
import { discoverPlugins, pluginConnections } from '@noetic-tools/agent-plugins';
import { AgentHarness } from '@noetic-tools/core';
const found = await discoverPlugins(['/home/alex/.agents/plugins'], '/home/alex/.agents/plugins-data');
const { connections, diagnostics } = await pluginConnections(found);
const harness = new AgentHarness({ name: 'agent', params: {}, connections });
// or: await harness.execute('Summarize the open reports.', { connections });- Names. The server
reports/apiis the connectionreports_api:<plugin>_<server>, with any character a connection name can't hold replaced by_(pluginConnectionName()). Two servers that map to the same name fail withCONNECTION_NAME_TAKEN. - Tools. Each entry uses
expose: 'call', so the model seesreports_api_opsandreports_api_calland nothing is added to the context each turn. Two or more'call'connections share oneopsand onecalltool that take atargetargument. - Gates. A plugin's tools are connection tools, so they pass every gate: the connection
policy,beforeToolCalllayers, approval, secret scrubbing, and an idempotency key that a replayed call repeats. They give the same tools and results as the same server attached withmcp(). - Failures.
pluginConnections()launches nothing. A server that fails to start or connect is an unavailable connection with no tools; every other connection keeps working.
An entry is a plain ConnectionConfig, so a host can add a policy before attaching it:
const guarded = connections.map((entry) => ({ ...entry, policy: { deny: ['*_delete'] } }));Options: transports (default ['stdio', 'streamable-http']; add 'sse' for the legacy transport), baseEnv (the ambient environment the subprocess allowlist draws from, default process.env) and timeoutMs (per-server connect budget). pluginConnections() creates each plugin's PLUGIN_DATA directory before any value names it. For one resolved server, pluginMcpProvider() returns the mcp() provider with the same environment allowlist and header filtering; it takes the connection name.
Moving from the layer's MCP client
Before 0.3.0 the layer connected plugin servers itself and offered the model a callMcpTool({ server, tool, arguments }) tool. That client is gone: callMcpTool, readMcpTools, connectMcpServer, closeSessions, the McpSession, McpToolInfo and McpConnectResult types, DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_TOOL_CALL_TIMEOUT_MS, and the layer's connectMcp, transports, baseEnv and connectTimeoutMs options. Attach pluginConnections()'s entries instead; the model then calls <plugin>_<server>_call({ op, args }), and each call passes every connection gate — policy, beforeToolCall, approval, secret scrubbing and replay-stable idempotency keys.
Client extensions
Agent Plugins reserves reverse-domain namespaces for client-specific data. Noetic claims tools.noetic, and surfaces it verbatim:
import { agentPlugins } from '@noetic-tools/agent-plugins';
const layer = agentPlugins({
roots: ['/home/alex/.agents/plugins'],
dataDir: '/home/alex/.agents/plugins-data',
});
const [plugin] = layer.readPlugins();
plugin?.noeticExtension; // extensions['tools.noetic'] from plugin.json
plugin?.noeticExtensionDir; // the tools.noetic/ directory, if presentSemantics are deliberately open — the data is passed through rather than interpreted, so nothing is locked in before a consumer exists. Namespaces belonging to other clients are ignored without being validated.
Skills-only hosts
A client may support skills without supporting MCP and still conform. Leave out pluginConnections(): the layer still discovers and validates the servers and lists them in mcpServers, and nothing is launched.