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.

▓ files
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
▓ plugins/hello/package.json
{
  "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" }
}
▓ plugins/hello/parel.plugin.json
{
  "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:

▓ plugins/hello/src/index.ts
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}` }),
    );
  },
});
▓ agent.yaml
plugins:
  - plugin: ./plugins/hello
    config:
      greeting: Hi

ctx.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?.(...).

FieldMeaning
configThe plugin's config from agent.yaml, with secrets and vars filled in.
storeKey-value store for this plugin in this session: get, set, delete, list.
instanceStoreKey-value store shared by every session of the same instance, with cas for safe updates. Can be undefined.
instanceThe instance: key (null for a temporary one) and ephemeral (true for parel try sessions and replays).
logLogger: debug, info, warn, error.
modelchat(params) calls the agent's own model with the agent's key.
inputsThe 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:

▓ tool returning media
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.

EventWhen it runsCan change
session:startThe session's plugins have started.-
session:resumeThe runtime picks the session up again. It can fire often, so keep it quick and safe to repeat. Reconnect to outside resources here.-
session:suspendThe runtime is about to put the session's work aside. Save handles here.-
session:endThe session ends.-
turn:startA turn starts.-
turn:endA turn finishes.-
step:startA step starts, after any messages that arrived during the turn were added.-
context:buildBefore each model call, with system and messages.system, messages
model:beforeJust before the model call, with modelParams.modelParams
model:afterAfter the model answers, with modelResponse.-
tool:beforeBefore each tool call, with toolCall.toolCall
tool:afterAfter each tool call, with toolCall and toolResult.toolResult
step:endA step finishes.-
checkpointA checkpoint was saved.-
errorThe 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.

ActionMeaning
continueGo on. May carry mutations.
skipGo on, without running the remaining hooks for this event.
blockRefuse, 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.
suspendLike block, with a reason; a tool result says suspended instead of blocked.
stopEnd 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.

▓ hooks
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.

▓ parel.plugin.json
{ "name": "@acme/notes", "provides": { "commands": ["note"] } }
▓ src/index.ts
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.storectx.instanceStore
Lives forOne sessionThe instance; survives new sessions
Written byOne turn at a timeSeveral sessions at once; use cas
Where you get itctx, hookCtx, toolCtx, commandCtxctx in setup only; keep a reference
▓ instanceStore
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:

CapabilityProvided byMeaning
parel.runtimethe runtimeStart 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.executionthe runtimeSnapshots, pause policies, resuming or cancelling pauses, and branching or replaying from a snapshot.
parel.sandbox, filesystem, exec, process, portssandbox-e2bThe sandbox itself: files, commands, background processes, ports.
workspaceworkspaceThe checked-out repository the tool plugins work in.
▓ src/index.ts
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.

FieldMeaning
namePackage name, the same as in package.json.
version, descriptionOptional. package.json is the source of truth for the version.
provides.tools, provides.hookstrue if the plugin registers tools or hooks.
provides.commandsSlash command names the plugin registers. Only these are routed to it.
provides.capabilitiesCapability names the plugin offers with ctx.provide.
provides.normalizeInput types the plugin turns into messages with ctx.normalize.
requires.capabilitiesCapabilities the plugin needs from other plugins or the runtime.
requires.permissionsnetwork, store, model, inputs: what the plugin uses.
requires.secretsConfig 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.invocationContexttrue: 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.snapshotHow snapshots, branches and replays treat the plugin: store (copy, redact, reset, custom), sandbox (copy, reset, unsupported, custom), sideEffects (reference, require_approval, deny_replay).
runtimeedge (the default) or nodejs. Hosted PAREL runs edge plugins only, and a nodejs plugin fails the deploy, so leave this out.
configFree-form notes that describe the plugin's config keys.
▓ parel.plugin.json
{
  "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:

▓ agent.yaml
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.

▓ shell
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.

▓ shell
(cd plugins/hello && npm install && npm run build)
parel deploy agent.yaml
# edit, rebuild, deploy again: each deploy uploads a fresh copy
For complete, working plugins, read the first-party ones in parel-oss/js/plugins: 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.

Writing Plugins · PAREL