Skip to content

Build your own plugin

This walkthrough uses @alphacro/agent-planning as the worked example — a real plugin that contributes tools, hooks, state, and RPC methods. By the end you’ll have a complete picture of how to build, wire, and test a plugin.

packages/agent-planning/src/plugin.ts
import type { Plugin } from "@alphacro/agent-core";
export function createPlanningPlugin(
options: CreatePlanningPluginOptions = {},
): Plugin<PlanningState> {
// ...
return {
name: "@alphacro/agent-planning",
version: "1.0.0",
description:
"Task DAG plugin: tasks, dependencies, and task-scoped tool decoration.",
// contributions follow
};
}

Plugins are created via factory functions (not classes) so the host can inject dependencies. The options object carries injected ports — stores, ID generators, clocks — that keep the plugin testable without mocks.

tools: () => [
createListTasksTool(store),
createGetNextTasksTool(store),
createUpsertTaskTool({ store, now }),
createCompleteTaskTool({ store, now }),
createCancelTaskTool({ store, now }),
createUpsertTaskDependencyTool({ store, newDependencyId, now }),
createDeleteTaskDependencyTool({ store, now }),
],

Each tool is a standalone ToolDefinition with its own input schema (Zod), execute function, and description. The composer collects them into a single ToolRegistry.

hooks: () => [
decorateToolSchemaHook(store, { excludeToolNames: exempt }),
preToolUseHook(store, { exemptToolNames: exempt, now }),
postToolUseHook(store, now),
decorateSystemPromptHook(store),
],

The planning plugin uses four hooks:

  • DecorateToolSchema — adds a task_ids field to every non-planning tool’s input schema while tasks are active, so the model scopes each action to a task.
  • PreToolUse — validates task_ids and auto-starts pending tasks on first reference.
  • PostToolUse — rolls up task.progress from tool results.
  • DecorateSystemPrompt — surfaces the active task list to the model.
rpcMethods: () =>
planningRpcMethods({ store, newTaskId, newDependencyId, now }),

RPC methods let the SPA drive the same store the model uses — the viewer can create, reorder, or delete tasks through the same interface.

initialState: () => initialPlanningState(),
reduce: (state) => state,

When a plugin owns durable state, it provides an initial state factory and a reducer. The runner hydrates state from the event log via reducePluginState.

In editor-web, the host calls composeAgentSessionPlugins:

composeAgentSessionPlugins({
prePlugins: [createCloudflareRuntimePlugin(), createTranspilePlugin()],
planning: planningHost.composed.plugins,
extraPlugins: [skillsPlugin, editorWebPlugin],
});

In the CLI, the same planning plugin is composed with a Node runtime adapter instead of the Cloudflare one — same plugin, different host.

Plugins are pure functions of their inputs. Test the factory, the tools, and the hooks independently with a mock HookContext:

const plugin = createPlanningPlugin({ store: new PlanningStore() });
const tools = plugin.tools!();
expect(tools.map((t) => t.name)).toContain("upsert_task");

No platform pool, no Durable Object, no edge runtime needed.

A plugin is a plain object with a factory function. It declares what it contributes (tools, hooks, state, RPC, etc.) and the runtime composes everything via composePlugins. The key principles:

  1. Factory functions — not classes. Inject dependencies via options.
  2. Typed contributions — tools, hooks, state slices are all typed interfaces.
  3. Collision-safe composition — the composer validates uniqueness at startup.
  4. Layer-2 portable — no Cloudflare types in plugin packages.
  5. Testable in isolation — no platform runtime needed for unit tests.