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.
npm install @kuralle-agents/plugins @kuralle-agents/fsLayout
Section titled “Layout”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| File | Role |
|---|---|
plugin.json | Identity 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.json | Declares mcpServers (stdio, streamable-http, or sse). Parsed only when the file exists; a missing file is not an error. See the MCP guide. |
Flows are a host extension
Section titled “Flows are a host extension”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.
Not the same as a file-authored agent
Section titled “Not the same as a file-authored agent”Both are a folder with a skills/ directory, so they look alike. They solve opposite problems.
| Agent Plugin | File-authored agent | |
|---|---|---|
| Whose code | someone else's, published | yours |
| Defines | a capability bundle to attach to an agent | the agent itself |
| Spec | Agent Plugins 1.0.0, portable across clients | Kuralle's own format |
| Manifest | plugin.json | agent.json + instructions.md |
| Model, policy, routing | not present — the host decides | part of the agent definition |
| MCP servers | declared in mcp.json | supplied by the host |
| Loaded | at runtime, by loadAgentPlugin(fs, root) | at build time, by kuralle build |
| Bad input | partial isolation — skip the broken part, keep the rest | the compiler rejects the build |
| Identity | the plugin name | a 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.
Quick start
Section titled “Quick start”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.
Five failure widths
Section titled “Five failure widths”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 failed | Outcome | Skills | MCP servers | Flows |
|---|---|---|---|---|
Bad plugin.json (missing, unreadable, schema violation) | Reject the whole plugin (ok: false) | not loaded | not loaded | not loaded |
Bad mcp.json (malformed, schema violation, unsafe config) | Disable MCP for this plugin — skills and flows still load | loaded | [] + diagnostics | unchanged |
| Bad skill folder (invalid frontmatter, name mismatch) | Skip that skill — rest of skills/ still loads | partial | unchanged | unchanged |
Bad server entry in mcp.json (unknown transport, SSRF, secret in env) | Skip that server — siblings still load | unchanged | partial + diagnostics | unchanged |
| Bad flow file (unreadable, bad JSON, envelope-schema violation, validation issues) | Skip that flow — siblings, skills, and MCP still load | unchanged | unchanged | partial + 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).
Worked example — partial skill failure
Section titled “Worked example — partial skill failure”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 frontmatterloadAgentPlugin 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.
LoadPluginResult
Section titled “LoadPluginResult”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.
MCP and platform limits
Section titled “MCP and platform limits”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.
How a stdio server is launched
Section titled “How a stdio server is launched”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.
${PLUGIN_ROOT} and ${PLUGIN_DATA}
Section titled “${PLUGIN_ROOT} and ${PLUGIN_DATA}”Two placeholders expand inside args, env, and cwd, and arrive as environment variables:
| Variable | Points at | Writable |
|---|---|---|
PLUGIN_ROOT | the plugin directory | treat as read-only |
PLUGIN_DATA | a private data directory for this plugin | yes |
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.
Containment is checked twice
Section titled “Containment is checked 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:
| When | What it catches | Why it cannot do the other job |
|---|---|---|
Parse — loadAgentPlugin | ../ 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 spawns | symlinks that resolve outside the permitted root | too 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.
Live example
Section titled “Live example”The repo vendors a verbatim copy of remotion-dev/codex-plugin and proves offline loading:
bun packages/plugins/examples/third-party-plugin.tsIt 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.
Related
Section titled “Related”- Skills — progressive disclosure for the
skills/component - MCP — connecting and governing plugin-declared MCP servers
- Flows — the
FlowDefinitiongraphsflows/*.flow.jsoncarry - Dynamic Flows —
runtime.addDynamicFlowsfor 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)