Contents
Writing Plugins
Plugins give an agent its tools, prompts, memory, guards and integrations. A plugin is an npm package whose default export is definePlugin(...) from @parel/plugin-sdk, with a parel.plugin.json manifest next to its package.json.
# Package layout
Publish built JavaScript as an ES module ("type": "module") and list parel.plugin.json in files so it ends up in the package. The runtime reads that file from the package when you deploy, and bundles your code together with its npm dependencies.
plugins/hello/
├── package.json name, version, "type": "module", exports
├── parel.plugin.json the manifest (must be in the published files)
├── src/index.ts export default definePlugin({ ... })
└── dist/index.js built output{
"name": "@acme/hello",
"version": "0.1.0",
"type": "module",
"exports": { ".": { "import": "./dist/index.js" } },
"files": ["dist", "parel.plugin.json"],
"scripts": { "build": "tsup src/index.ts --format esm" },
"dependencies": { "@parel/plugin-sdk": "^0.17.1" },
"devDependencies": { "tsup": "^8.0.0", "typescript": "^5.8.0" }
}{
"name": "@acme/hello",
"provides": { "tools": true, "hooks": true, "commands": ["greetings"] },
"execution": { "snapshot": { "store": "copy", "sideEffects": "reference" } }
}# definePlugin
setup(ctx) runs when a session's plugins start. Register tools, hooks and commands there, and keep what the handlers need (config values, stores, capabilities) in local variables. An optional teardown(ctx) runs, best effort, when the session ends. This plugin adds a tool, a hook and a slash command:
import { definePlugin, LifecycleEvent, type ParelPlugin } from "@parel/plugin-sdk";
import manifest from "../parel.plugin.json" with { type: "json" };
export default definePlugin({
name: "@acme/hello",
// The runtime reads parel.plugin.json; hand the same declarations to
// definePlugin so the two never disagree.
provides: manifest.provides as ParelPlugin["provides"],
execution: manifest.execution as ParelPlugin["execution"],
async setup(ctx) {
const greeting = (ctx.config.greeting as string | undefined) ?? "Hello";
const store = ctx.store; // hooks, tools and commands use it through this closure
ctx.tool(
{
name: "greet",
description: "Greet someone by name",
parameters: {
type: "object",
properties: { name: { type: "string", description: "Who to greet" } },
required: ["name"],
},
},
async (params) => {
const count = ((await store.get<number>("count")) ?? 0) + 1;
await store.set("count", count);
return `${greeting}, ${params.name}! (greeting #${count})`;
},
);
ctx.hook(LifecycleEvent.ContextBuild, async (hookCtx) => ({
action: "continue",
mutations: {
system: hookCtx.system
? `${hookCtx.system}\n\nGreet people warmly.`
: "Greet people warmly.",
},
}));
ctx.command?.(
{ name: "greetings", description: "Show how many greetings were sent" },
async () => ({ reply: `Greetings so far: ${(await store.get<number>("count")) ?? 0}` }),
);
},
});plugins:
- plugin: ./plugins/hello
config:
greeting: Hictx.config holds the entry's config from agent.yaml, with secret references and instance vars already filled in.
# The setup context
Everything a plugin can use arrives on ctx in setup. Methods marked with ? may be missing on older runtimes, so call them as ctx.command?.(...).
| Field | Meaning |
|---|---|
| config | The plugin's config from agent.yaml, with secrets and vars filled in. |
| store | Key-value store for this plugin in this session: get, set, delete, list. |
| instanceStore | Key-value store shared by every session of the same instance, with cas for safe updates. Can be undefined. |
| instance | The instance: key (null for a temporary one) and ephemeral (true for parel try sessions and replays). |
| log | Logger: debug, info, warn, error. |
| model | chat(params) calls the agent's own model with the agent's key. |
| inputs | The session's input queue: peek, drain and push inputs by type. |
| hook(event, handler, options?) | Run code at a point in the session's life. See Hooks. |
| tool(definition, handler, options?) | Give the model a tool. See Tools. |
| command?(definition, handler) | Add a slash command the user can type. See Slash commands. |
| normalize?(types, handler, options?) | Turn incoming platform inputs of the given types into conversation messages. |
| provide(name, impl) / require(name) | Offer a capability to other plugins, or get one from another plugin or the runtime. See Capabilities. |
| interrupt() | Ask the runtime to stop the running turn. |
# Tools
ctx.tool(definition, handler, options?) gives the model a tool. The definition has a name (unique across all of the agent's plugins), a description and JSON Schema parameters. The handler gets (params, toolCtx), where toolCtx has session, store, log, invocation (the ids of this call) and, if the manifest asks for it, invocationContext. Return a string, or a ToolOutput with content, an optional isError, and media for images and files as base64:
ctx.tool(
{
name: "render_chart",
description: "Render the data as a PNG chart",
parameters: { type: "object", properties: { csv: { type: "string" } }, required: ["csv"] },
// Optional: let the runtime run several calls of this tool at once.
scheduling: { defaultMode: "parallel", maxConcurrency: 2 },
},
async (params, toolCtx) => {
if (typeof params.csv !== "string" || params.csv.length === 0) {
return { content: "csv is empty", isError: true };
}
toolCtx.log.info("rendering", { callId: toolCtx.invocation?.toolCallId });
const png = await renderPng(params.csv); // your code: base64 PNG bytes
return {
content: "Chart attached.",
media: [{ data: png, mediaType: "image/png", filename: "chart.png" }],
};
},
);Long text is cut to runtime.toolResultMaxBytes. Media has its own size budget, and items over it are dropped with a note. Tools run one at a time unless their definition sets scheduling.defaultMode to parallel (optionally with maxConcurrency); runtime.maxParallelToolCalls caps how many run together. The isConcurrencySafe registration option isn't used on hosted PAREL.
# Hooks
ctx.hook(event, handler, options?) runs code at a point in a session. Use the LifecycleEvent constants or the event names below. Every handler gets hookCtx with session (ids, status, turn and step counts, tokens, cost; read only), store, inputs, tools (register or unregister tools on the fly), transcript (read the conversation) and turn (turn id, step number, inputs taken in so far), plus the event's own fields.
| Event | When it runs | Can change |
|---|---|---|
| session:start | The session's plugins have started. | - |
| session:resume | The runtime picks the session up again. It can fire often, so keep it quick and safe to repeat. Reconnect to outside resources here. | - |
| session:suspend | The runtime is about to put the session's work aside. Save handles here. | - |
| session:end | The session ends. | - |
| turn:start | A turn starts. | - |
| turn:end | A turn finishes. | - |
| step:start | A step starts, after any messages that arrived during the turn were added. | - |
| context:build | Before each model call, with system and messages. | system, messages |
| model:before | Just before the model call, with modelParams. | modelParams |
| model:after | After the model answers, with modelResponse. | - |
| tool:before | Before each tool call, with toolCall. | toolCall |
| tool:after | After each tool call, with toolCall and toolResult. | toolResult |
| step:end | A step finishes. | - |
| checkpoint | A checkpoint was saved. | - |
| error | The runtime hit an error, with error. | - |
A handler returns an action, or nothing to continue. To change something, return action: "continue" with a mutations object holding fields from the Can change column.
| Action | Meaning |
|---|---|
| continue | Go on. May carry mutations. |
| skip | Go on, without running the remaining hooks for this event. |
| block | Refuse, with a reason. At model:before and step:end the turn ends; at tool:before the tool doesn't run and the model gets "Tool call blocked: REASON" as the result. |
| suspend | Like block, with a reason; a tool result says suspended instead of blocked. |
| stop | End the turn, with a reason. It acts at model:before and step:end; at tool:before it only skips the remaining hooks. |
Only model:before, tool:before and step:end act on these actions. Every other event ignores them and runs all of its hooks, even when one throws. Hooks run in order of priority, lowest first (HookPriority: Early 0, Security 10, Normal 100 by default, Late 200, Logging 250), and each one sees the changes made by the ones before it.
import { HookPriority, LifecycleEvent } from "@parel/plugin-sdk";
ctx.hook(
LifecycleEvent.ToolBefore,
async (hookCtx) => {
const command = String(hookCtx.toolCall.arguments.command ?? "");
if (command.includes("rm -rf /")) {
return { action: "block", reason: "Destructive command" };
}
},
{ priority: HookPriority.Security },
);# Slash commands
When a user's message starts with /name, it goes to the plugin that registered name instead of becoming a message. Declare every name in provides.commands in the manifest: the runtime only routes declared names, and ignores, with a warning, a registration it wasn't told about. Names are lowercase letters, digits, _ and -, start with a letter, and must be unique across the agent's plugins.
{ "name": "@acme/notes", "provides": { "commands": ["note"] } }ctx.command?.(
{ name: "note", description: "Save a note", args: { description: "the note text" } },
async (args, commandCtx) => {
if (!args) return { reply: "Usage: /note TEXT" };
await commandCtx.store.set(`note:${Date.now()}`, args);
return { reply: "Saved." }; // shown to the user only
// or: return { prompt: `Summarize my notes about ${args}` }; // starts a turn
},
);The handler gets the text after the name (trimmed) and commandCtx with session, store, log and transcript. Return reply to show text to the user only (it never reaches the model or the conversation), or prompt to start a turn with that text as the user's message. Commands run between turns: right away when the session is idle, otherwise after the running turn, in order with other messages. A command can run more than once, so make it safe to repeat. /help is built in and lists the session's commands.
# Plugin state
Pick the store with one question: if the user starts a new conversation, should this data still be there? No: ctx.store. Yes: ctx.instanceStore. Each plugin gets its own space in both; plugins don't see each other's keys.
| ctx.store | ctx.instanceStore | |
|---|---|---|
| Lives for | One session | The instance; survives new sessions |
| Written by | One turn at a time | Several sessions at once; use cas |
| Where you get it | ctx, hookCtx, toolCtx, commandCtx | ctx in setup only; keep a reference |
async setup(ctx) {
const istore = ctx.instanceStore; // only on the setup context: keep a reference
ctx.hook(LifecycleEvent.TurnStart, async () => {
if (!istore) return; // no instance storage here: say so, don't fake it
const current = await istore.get<{ id: string }>("sandbox");
if (current) return;
const created = await createSandbox(); // your code
const won = await istore.cas("sandbox", null, { id: created.id });
if (!won) await created.dispose(); // another session got there first
});
}cas(key, expectedVersion, value) writes only if the key's version still matches (null means the key must not exist yet) and returns whether it won. There are no instance lifecycle hooks: resetting an instance simply empties the store, so treat missing state as a fresh start. Skip expensive saving when ctx.instance.ephemeral is true. instanceStore is undefined on runtimes without instance storage; tell the user instead of quietly using the session store.
# Capabilities
Plugins share services through named capabilities. ctx.provide(name, impl) offers one, and ctx.require(name) gets it (it throws when nobody provides it). List what you offer in provides.capabilities and what you need in requires.capabilities of the object you pass to definePlugin: the runtime then sets plugins up in the right order, and setup fails if something required has no provider. The runtime provides two capabilities itself:
| Capability | Provided by | Meaning |
|---|---|---|
| parel.runtime | the runtime | Start and manage child sessions: startChildSession, getChild, cancelChild, signalChild. An async child's result comes back to the parent as a new turn. The subagent plugin uses it. |
| parel.execution | the runtime | Snapshots, pause policies, resuming or cancelling pauses, and branching or replaying from a snapshot. |
| parel.sandbox, filesystem, exec, process, ports | sandbox-e2b | The sandbox itself: files, commands, background processes, ports. |
| workspace | workspace | The checked-out repository the tool plugins work in. |
import { definePlugin, PAREL_RUNTIME_CAPABILITY, type RuntimeControl } from "@parel/plugin-sdk";
export default definePlugin({
name: "@acme/delegate",
requires: { capabilities: [PAREL_RUNTIME_CAPABILITY] },
async setup(ctx) {
const runtime = ctx.require<RuntimeControl>(PAREL_RUNTIME_CAPABILITY);
ctx.tool(
{
name: "delegate",
description: "Hand a task to the researcher agent",
parameters: { type: "object", properties: { task: { type: "string" } }, required: ["task"] },
},
async (params, toolCtx) => {
const child = await runtime.startChildSession({
context: "fresh",
agent: "agt_a1B2c3D4e5F6", // the child agent's id
input: String(params.task),
idempotencyKey: `${toolCtx.session.id}:${toolCtx.invocation?.toolCallId}`,
});
return `Started child session ${child.childSessionId}; its answer arrives as a new turn.`;
},
);
},
});# parel.plugin.json
The manifest sits at the package root. The runtime reads it from the package (or the uploaded local copy) when you deploy, before any of your code runs. Only name is required.
| Field | Meaning |
|---|---|
| name | Package name, the same as in package.json. |
| version, description | Optional. package.json is the source of truth for the version. |
| provides.tools, provides.hooks | true if the plugin registers tools or hooks. |
| provides.commands | Slash command names the plugin registers. Only these are routed to it. |
| provides.capabilities | Capability names the plugin offers with ctx.provide. |
| provides.normalize | Input types the plugin turns into messages with ctx.normalize. |
| requires.capabilities | Capabilities the plugin needs from other plugins or the runtime. |
| requires.permissions | network, store, model, inputs: what the plugin uses. |
| requires.secrets | Config fields that hold secrets: each field name maps to a description and required (default true). In agent.yaml such a field must be a ${NAME} reference; a plain value, or a required field left out, fails the deploy. The values are kept out of logs and snapshots. |
| consumes.invocationContext | true: tool handlers get toolCtx.invocationContext, the per-turn metadata the caller attached to the message (the context field of POST /sessions/ID/messages). |
| consumes.transcript | "lazy": the plugin reads history through hookCtx.transcript.read(...) and doesn't need the full message list on every hook. |
| execution.snapshot | How snapshots, branches and replays treat the plugin: store (copy, redact, reset, custom), sandbox (copy, reset, unsupported, custom), sideEffects (reference, require_approval, deny_replay). |
| runtime | edge (the default) or nodejs. Hosted PAREL runs edge plugins only, and a nodejs plugin fails the deploy, so leave this out. |
| config | Free-form notes that describe the plugin's config keys. |
{
"name": "@acme/crm-tools",
"description": "Look up customers in Acme CRM.",
"provides": { "tools": true },
"requires": {
"permissions": { "network": true },
"secrets": {
"apiToken": { "description": "Acme CRM API token", "required": true }
}
},
"consumes": { "invocationContext": true },
"execution": { "snapshot": { "store": "reset", "sideEffects": "require_approval" } }
}With this manifest, the agent must bind the token to a secret:
plugins:
- plugin: "@acme/crm-tools"
version: "^1.0.0"
config:
apiToken: ${ACME_CRM_TOKEN}# Where plugins run
On hosted PAREL, plugins run in a Cloudflare Worker isolate with Node.js compatibility turned on (nodejs_compat). Packages that need native modules, child processes or a real file system won't load. Each session gets its own warm plugin runtime; keep state that must last in the stores, not in module variables.
Plugins can reach the public internet with fetch; requests to private or internal addresses (localhost, private IP ranges, cloud metadata) are blocked. Secret config values arrive already filled in. Declare those fields in requires.secrets, so a plain value there is rejected at deploy and the values are kept out of logs.
# Ship it
As an npm package
Publish the package publicly to npm, then use the full form in agent.yaml with the package name and a version range. The range is resolved to one exact version when you deploy; deploy again to pick up a new release. Short names only resolve to @parel/* packages, so write your full package name.
npm run build npm publish --access public # then in agent.yaml: # - plugin: "@acme/hello" # version: "^0.1.0" parel deploy agent.yaml
From a local directory
While developing, point agent.yaml at the plugin's directory and deploy with the CLI. The CLI packs the directory with pnpm pack (or npm pack), so build first and make sure files includes the build output and parel.plugin.json. Every deploy uploads a fresh copy. Deploys through the HTTP API can't use local paths.
(cd plugins/hello && npm install && npm run build) parel deploy agent.yaml # edit, rebuild, deploy again: each deploy uploads a fresh copy
system-static and budget-cap are a few lines each, memory-rolling-summary shows a slash command and lazy transcript reads, and sandbox-e2b shows secrets, capabilities and the instance store.# Model providers
Model providers are not plugins and can't be loaded from npm. Pick one with the top-level model block: anthropic, openai, openai-responses, openai-compatible or anthropic-compatible. See the agent.yaml reference.