Skip to content

Agent Plugins

An Agent Plugin is a directory with a fixed layout: a plugin.json manifest at the root, a skills/ tree of agentskills.io folders, an optional mcp.json that declares MCP servers to connect, and — as a Kuralle host extension — an optional flows/ directory of declarative flow graphs. Kuralle loads the bundle with loadAgentPlugin(fs, root) and returns a discriminated result — never throws — so callers can wire skills, MCP, and flows independently of whatever failed inside the bundle.

Terminal window
npm install @kuralle-agents/plugins @kuralle-agents/fs
my-plugin/
├── plugin.json # required manifest
├── skills/ # optional — SKILL.md folders
│ └── returns-policy/
│ └── SKILL.md
├── flows/ # optional — Kuralle host extension
│ └── returns.flow.json
└── mcp.json # optional — MCP server declarations
FileRole
plugin.jsonIdentity and metadata (name, $schema, optional version, license, …). Validated against the Agent Plugins schema.
skills/Top-level skill directories only — nested SKILL.md files inside a skill folder are bundled resources, not separate skills. Wired into AgentConfig.skills via the returned SkillStoreLike.
flows/Optional Kuralle host extension. Top-level *.flow.json files are validated as FlowDefinition graphs and returned on plugin.flows — the host registers them with runtime.addDynamicFlows. A missing directory is not an error.
mcp.jsonDeclares mcpServers (stdio, streamable-http, or sse). Parsed only when the file exists; a missing file is not an error. See the MCP guide.

Agent Plugins 1.0.0 does not define flows/. Other hosts ignore unknown directories, so a plugin that ships flows/*.flow.json stays portable. Kuralle reads those files as declarative FlowDefinition graphs.

loadAgentPlugin validates them and returns them on plugin.flows. It does not register them — the host decides that with runtime.addDynamicFlows. Pass hostTools when loading so an action node cannot name a tool the host did not register; the plugin's own mcp.json server names are also prospective tools. Omit hostTools and the validator skips tool-reference checks (the registry index is gated: an absent kind is not a failure) rather than rejecting every action node. Policy still gates execution.

Both are a folder with a skills/ directory, so they look alike. They solve opposite problems.

Agent PluginFile-authored agent
Whose codesomeone else's, publishedyours
Definesa capability bundle to attach to an agentthe agent itself
SpecAgent Plugins 1.0.0, portable across clientsKuralle's own format
Manifestplugin.jsonagent.json + instructions.md
Model, policy, routingnot present — the host decidespart of the agent definition
MCP serversdeclared in mcp.jsonsupplied by the host
Loadedat runtime, by loadAgentPlugin(fs, root)at build time, by kuralle build
Bad inputpartial isolation — skip the broken part, keep the restthe compiler rejects the build
Identitythe plugin namea content-addressed artifact digest

The difference in the last two rows follows from the first. You wrote a file-authored agent, so a mistake in it is a bug to fix before shipping and the compiler should stop you. You did not write the plugin, so a mistake in it must not take down an agent that merely loads it — hence five failure widths instead of a thrown error.

They compose: a file-authored agent can load plugins at runtime, and both feed AgentConfig.skills through the same SkillStoreLike.

Mirror a plugin directory onto any FileSystem (disk, in-memory, Durable-Object SQLite) and load it:

import { loadAgentPlugin } from '@kuralle-agents/plugins';
import { InMemoryFs } from '@kuralle-agents/fs';
const fs = new InMemoryFs({
'/plugins/acme/plugin.json': JSON.stringify({
$schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json',
name: 'acme',
version: '1.0.0',
}),
'/plugins/acme/skills/returns-policy/SKILL.md':
'---\nname: returns-policy\ndescription: Handle returns.\n---\n\n# Returns\n30-day window.',
});
const result = await loadAgentPlugin(fs, '/plugins/acme');
if (!result.ok) {
console.error(result.rejection.message);
return;
}
const { manifest, skills, mcpServers, flows, diagnostics } = result.plugin;
const agent = defineAgent({
id: 'support',
model,
workspace: fs,
skills, // SkillStoreLike — progressive disclosure handled by the runtime
// mcpServers → pass to mcpTools() — see /guides/mcp
// flows → runtime.addDynamicFlows(flows, { agentId: 'support' }) — host registration, not the loader
});

loadAgentPlugin always resolves paths under root. Manifest, MCP, or flow paths that escape the plugin root are rejected — see Containment for what "escape" means once symlinks are involved.

The loader applies different blast radii depending on what broke. That is why loading returns { ok: true, plugin } | { ok: false, rejection, diagnostics } instead of throwing.

What failedOutcomeSkillsMCP serversFlows
Bad plugin.json (missing, unreadable, schema violation)Reject the whole plugin (ok: false)not loadednot loadednot loaded
Bad mcp.json (malformed, schema violation, unsafe config)Disable MCP for this plugin — skills and flows still loadloaded[] + diagnosticsunchanged
Bad skill folder (invalid frontmatter, name mismatch)Skip that skill — rest of skills/ still loadspartialunchangedunchanged
Bad server entry in mcp.json (unknown transport, SSRF, secret in env)Skip that server — siblings still loadunchangedpartial + diagnosticsunchanged
Bad flow file (unreadable, bad JSON, envelope-schema violation, validation issues)Skip that flow — siblings, skills, and MCP still loadunchangedunchangedpartial + diagnostics

Diagnostics carry { section, rule, origin, message } so you can log or surface them without guessing which layer failed. A missing mcp.json, skills/, or flows/ directory produces no diagnostics — those components are optional (skills/ and mcp.json by spec §6.2; flows/ as a host extension).

Given a plugin with one valid skill and one malformed SKILL.md:

plugin/
├── plugin.json
└── skills/
├── good-skill/SKILL.md # valid
└── bad-skill/SKILL.md # invalid frontmatter

loadAgentPlugin returns ok: true with one skill (good-skill) and a diagnostic on skills/bad-skill/SKILL.md (section: "7.1", rule: "skill-invalid"). The agent still gets the good skill; the bad folder is skipped.

Given a malformed mcp.json instead, skills still load, mcpServers is [], and a diagnostic marks mcp.json invalid — MCP is disabled for that plugin, not the whole bundle.

Given one valid flows/*.flow.json and one malformed sibling, the valid flow is returned on plugin.flows and a diagnostic with section: "flows" names the broken file. Skills and MCP are untouched.

type LoadPluginResult =
| { ok: true; plugin: LoadedPlugin }
| { ok: false; rejection: Rejection; diagnostics: readonly Diagnostic[] };
interface LoadedPlugin {
manifest: PluginManifest;
skills: SkillStoreLike;
mcpServers: readonly McpServerConfig[];
flows: readonly FlowDefinition[]; // validated, not registered
diagnostics: readonly Diagnostic[];
}

On success, check diagnostics even when ok === true — partial failures surface there. On rejection, rejection names the manifest rule that failed; diagnostics repeats the same facts for uniform logging.

Plugin MCP entries may declare stdio servers (command, args, env, cwd). stdio cannot run on Cloudflare Workers — there is no subprocess in workerd. Remote transports (streamable-http, sse) work on Workers and Durable Objects; stdio is available on Node and Bun via the @kuralle-agents/mcp/node subpath. That is a platform limit, not a missing install.

See the MCP guide for connecting servers, SSRF guards, disclosure budgets, and wiring tools into an agent.

A stdio entry is not a request — it is a program this machine runs. Four things are decided for you before the process starts.

{
"mcpServers": {
"local": {
"type": "stdio",
"command": "./bin/server", // resolved against the plugin root
"args": ["--data", "${PLUGIN_DATA}"],
"env": { "LOG_LEVEL": "debug" },
"cwd": "${PLUGIN_DATA}" // optional; defaults to the plugin root
}
}
}

command is either a bare token (npx, uvx, python) resolved through the platform search path, or a plugin-relative ./… path resolved against the plugin root. Nothing else is accepted.

cwd defaults to the plugin root when omitted (§7.2.1). When given, it must be ./…, ${PLUGIN_ROOT}, or ${PLUGIN_DATA}, and it must stay under the root it names.

env is composed, not inherited. The subprocess starts from a fixed base — PATH, HOME, TMPDIR, LANG, LC_ALL, and the Windows equivalents SystemRoot, PATHEXT, APPDATA — then the plugin's own env, then the reserved variables last, which a plugin can never override.

Two placeholders expand inside args, env, and cwd, and arrive as environment variables:

VariablePoints atWritable
PLUGIN_ROOTthe plugin directorytreat as read-only
PLUGIN_DATAa private data directory for this pluginyes

PLUGIN_DATA is a sibling of the plugin directory, keyed by plugin name — a plugin at /plugins/acme gets /plugins/data/acme. Keeping it outside the plugin root means writing state never mutates the distributed bundle, so a plugin stays byte-identical to what was published.

The client creates it and proves it writable before the subprocess starts, so a server can write on its first line without a mkdir of its own. If it cannot be created, that one server entry is skipped with a diagnostic and the plugin's other components still load.

Expansion is single-pass: text introduced by one substitution is never rescanned, so a path that happens to contain ${PLUGIN_ROOT} cannot expand twice.

§4.1 requires a plugin's paths to stay inside the plugin, and §4.1(3) defines that against the filesystem-resolved path — symlinks followed. A plugin can ship bin/server as a symlink to /usr/bin/curl; the string ./bin/server looks perfectly contained.

Two checks run, at different moments, because one moment cannot serve both rules:

WhenWhat it catchesWhy it cannot do the other job
ParseloadAgentPlugin../ escapes, cheaply and early${PLUGIN_DATA} does not exist yet, so resolving it would reject the specification's own cwd example
Launch — before the subprocess spawnssymlinks that resolve outside the permitted roottoo late to stop a plugin from loading at all, and it never runs on Workers

A failure at either point invalidates that one server entry under §7.2.2 — section: "4.1", rule: "path-escapes-plugin-root" — and the two are deliberately indistinguishable to a consumer. The plugin's skills and its other servers still load (§11.3). A flows/*.flow.json symlink that resolves outside the plugin root is skipped the same way (section: "flows", same rule) — that one flow is dropped; siblings, skills, and MCP still load.

A symlink that stays inside the plugin root is permitted, explicitly. This is a containment check, not a ban on symlinks.

The repo vendors a verbatim copy of remotion-dev/codex-plugin and proves offline loading:

Terminal window
bun packages/plugins/examples/third-party-plugin.ts

It asserts manifest metadata, exactly twelve skills, non-empty descriptions, a substantive loadBody('remotion-best-practices'), zero MCP servers, and zero diagnostics — the sharp case for a skills-only published plugin.

  • Skills — progressive disclosure for the skills/ component
  • MCP — connecting and governing plugin-declared MCP servers
  • Flows — the FlowDefinition graphs flows/*.flow.json carry
  • Dynamic Flowsruntime.addDynamicFlows for host registration
  • File-authored Agents — the folder format for your own agent, not a third-party bundle
  • Workspace — mount plugins on a workspace filesystem
  • Tool Policy — approval gate for MCP tool calls (not plugin loading)