NOETIC
Framework
Context Layers

Agent Plugins

A conformant Agent Plugins v1 client — discovers plugin packages from agent-plugins.org and exposes their Agent Skills and MCP servers to the model using progressive disclosure.

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:

  • Skillsskills/<name>/SKILL.md, conforming to the Agent Skills specification
  • MCP serversmcp.json, over stdio, Streamable HTTP, or the legacy HTTP+SSE transport

agentPlugins() discovers those packages and puts them 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.

  • 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. It depends on the MCP SDK and spawns subprocesses, and neither belongs in core's dependency graph.

Installation

bun add @noetic-tools/agent-plugins

Usage

import { agentPlugins } from '@noetic-tools/agent-plugins';
import { AgentHarness } from '@noetic-tools/core';

const plugins = agentPlugins({
  roots: ['/home/alex/.agents/plugins'],
  dataDir: '/home/alex/.agents/plugins-data',
});

const harness = new AgentHarness({
  name: 'my-agent',
  params: {},
  // Context layers go under `contextLayers`, as a plain array.
  contextLayers: [plugins],
  // 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 { McpTransport } from '@noetic-tools/agent-plugins';
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. Each plugin gets
   * `<dataDir>/<plugin-name>`, created before its first subprocess launches
   * and preserved across plugin updates.
   */
  dataDir: string;
  /** MCP transports to connect. Default: ['stdio', 'streamable-http']. */
  transports?: readonly McpTransport[];
  /** Connect MCP servers at all. Default: true. */
  connectMcp?: boolean;
  /** Ambient environment for the inherited allowlist. Default: process.env. */
  baseEnv?: Record<string, string | undefined>;
  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>
Call callMcpTool to invoke a tool on a connected server.
- reports/api: fetch_report, list_reports
</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
callMcpTool({ server, tool, arguments })Invokes a tool on a connected MCP server

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.

callMcpTool is only registered when connectMcp is enabled, so a skills-only host does not carry a dead tool.

Exposed data

KeyContents
pluginsEvery loaded plugin, with its manifest, root, and data directory
skillsEvery discovered skill, with frontmatter and body
mcpServersResolved, launch-ready server configurations
mcpToolsTools exposed by connected servers
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 never stops the plugin's skills 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 was valid but would not start or connect
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 layer 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.

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. Set connectMcp: false to skip connecting servers entirely; they are still discovered and validated, and reported through mcpServers.

import { agentPlugins } from '@noetic-tools/agent-plugins';

agentPlugins({
  roots: ['/home/alex/.agents/plugins'],
  dataDir: '/home/alex/.agents/plugins-data',
  connectMcp: false,
});

On this page