NOETIC
Framework
Context Layers

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-plugins

Usage

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'].

FunctionPurpose
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

KeyContents
pluginsEvery loaded plugin, with its manifest, root, and data directory
skillsEvery discovered skill, with frontmatter and body
mcpServersEach plugin server (<plugin>/<server>) and its connection name
diagnosticsEverything that was skipped or rejected, and why
activeSkillsQualified 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.

CodeMeaning
plugin-rejectedThe plugin is unusable; none of its components load
unknown-manifest-fieldAn unknown plugin.json field was reported and ignored
invalid-extensionsextensions was not an object; the field was ignored
component-type-invalidA fixed location was present but the wrong kind
skill-skippedA skill does not conform to the Agent Skills spec
mcp-disabledmcp.json is unusable; MCP is off for this plugin only
mcp-server-invalidOne server entry is invalid; siblings still load
mcp-transport-unsupportedThe host has not enabled that entry's transport
mcp-connect-failedThe server's PLUGIN_DATA directory could not be created, so it was not attached
skill-warningA skill loaded, but carries a frontmatter key the spec does not define
root-unreadableA configured scan root could not be read
root-emptyA 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 realpath before use. A symlink may point outside the plugin directory, so a lexical check would let skills/x look contained while resolving to /etc. Paths that cannot be resolved are treated as escapes — the check fails closed.
  • readSkillResource is contained to the skill directory, which is stricter than the spec requires and stops one skill reading another's files.
  • MCP command values 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_ROOT and PLUGIN_DATA are 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 env values 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/api is the connection reports_api: <plugin>_<server>, with any character a connection name can't hold replaced by _ (pluginConnectionName()). Two servers that map to the same name fail with CONNECTION_NAME_TAKEN.
  • Tools. Each entry uses expose: 'call', so the model sees reports_api_ops and reports_api_call and nothing is added to the context each turn. Two or more 'call' connections share one ops and one call tool that take a target argument.
  • Gates. A plugin's tools are connection tools, so they pass every gate: the connection policy, beforeToolCall layers, approval, secret scrubbing, and an idempotency key that a replayed call repeats. They give the same tools and results as the same server attached with mcp().
  • 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 present

Semantics 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.

On this page