Swarm Extensions
Write, install, and manage swarm extensions: TypeScript hook bundles that block, modify, or observe task creation, follow-ups, Slack routing, heartbeat remediation, and tool calls. Covers installing predefined catalog extensions (templates/extensions, YAML or JSON manifests) by name and, when EXTENSION_ALLOW_INLINE_INSTALL is on, installing an inline manifest plus files as a lead, plus the scripts, schedules, workflows and skills extensions ship, the hook contract, where to fetch swarm-extension.d.ts, the extension-catalog, extension-install and extension-list tools, config schemas, and the operator enable step.
Template Content
Swarm Extensions
An extension is a trusted TypeScript hook bundle that runs inside the swarm API server. Use one when a rule must apply to the whole swarm: block or rewrite new tasks, route Slack channels, suppress follow-ups, change stalled-task remediation, guard tool calls, or react after a task or tool call finishes.
Do not use an extension for work that one task, one script, or one schedule can do.
Two ways to install
- Catalog (always on): a predefined extension in the repo (
templates/extensions/<name>/), installed by name. Any authenticated agent can do it. To ship a catalog extension, open a PR that adds a template directory (see Bundle shape below); it becomes installable once that build is deployed. - Inline (opt-in): a
manifestplus afilesmap in the install request, with no PR and no deploy. It is off unless an operator setsEXTENSION_ALLOW_INLINE_INSTALL=true(dashboard Settings > Configuration > Security & access, or the API environment). With the flag off the install fails withinline_install_disabled. With it on, only a lead, operator, or dashboard user may send an inline bundle; a worker gets 403 and stays catalog-only. An inline bundle runs as code in the API process once enabled, so write it as carefully as a catalog one.
Both paths run the same validation and both land disabled.
Tools
extension-catalog, extension-install and extension-list. They are deferred. Load them with your harness tool search before the first call.
extension-catalog lists the predefined extensions, the assets each declares, and whether it is installed. extension-install takes { template: "<name>" } or, for an inline bundle, { manifest, files } (plus optional priority and config). template and manifest/files are mutually exclusive. It validates the bundle (manifest, imports, typecheck against the hook contract, script typecheck) and stores it. Any authenticated agent, including a worker, can install a catalog template. Your agent is recorded as its owner in createdByAgentId; extension-list exposes that field. Always send your X-Agent-ID when using REST so ownership is attributed to you.
Install also creates the ext:<name> agent and every asset the extension declares. Scripts are live and callable right away (scripts have no enabled state). Schedules, workflows, and skills are created disabled. A disabled workflow ignores its triggers and refuses manual triggers.
An extension skill is global, not swarm-wide, so no agent sees it until someone installs it on that agent. The extension never does this. After the extension is enabled, install the skill on each agent that needs it with skill-install. skill-install refuses a disabled skill, so enable the extension first.
Workers may install subsequent versions only for their own extensions, and PATCH or DELETE their own disabled drafts. Ownership stays with the original creator; each version records its writer as changedByAgentId. Leads, operators, and dashboard users retain access to all bundles.
A new install is disabled and inert. A lead, operator, or dashboard user must enable it with extension-enable or POST /api/extensions/{id}/enable. Enabling turns its schedules, workflows, and skills on; disabling turns them off and remembers which ones you had turned off. Workers cannot enable, disable, or activate versions. Report the extension name and the required activation step.
Installing a template or inline bundle whose content changed stages a new version without activating it. An operator or dashboard user reinstalling an enabled extension activates the new version immediately; a lead never does. Workers may stage updates while an extension is enabled, but cannot PATCH or change its live config/priority. Ask a lead or operator to make those changes. EXTENSION_ALLOW_LEAD_ACTIVATION=false restricts activation to operators/dashboard users.
Uninstall (extension-delete, disabled extensions only) deletes the assets you never edited and detaches the ones you did, so your edits survive.
Get the contract before you write hooks
Fetch the generated type definitions and read them. Do not guess event names or field names.
curl -s "$MCP_BASE_URL/api/extensions/type-defs" \
-H "X-Agent-ID: $AGENT_ID" -H "Authorization: Bearer ${AGENT_SWARM_API_KEY:-$API_KEY}"
$MCP_BASE_URL, $AGENT_ID, and $AGENT_SWARM_API_KEY (or $API_KEY) are in every worker environment. The response is the swarm-extension module declaration: every event, its payload, and what a modify result may change.
Events
| Event | When | Result |
|---|---|---|
pre.task.create | before any task is stored (REST, MCP, Slack, schedule, workflow, webhook, follow-up) | block(reason), modify({ priority, agentId, tags, description, ... }), or nothing |
pre.task.followUp | before the lead follow-up task for a finished worker task | block, modify({ description, priority, agentId }), or nothing |
pre.slack.route | before a Slack message becomes a task | modify({ target: { kind: "agent", agentId } }), { kind: "lead" }, { kind: "broadcast" } |
pre.chat.route | before buffered Chat SDK messages (Telegram, Slack, Discord, Teams) become a task | block(reason), or modify({ target: { kind: "agent", agentId } }), { kind: "lead" }, { kind: "broadcast" }, { kind: "workflow", workflowId } |
pre.heartbeat.remediate | after the heartbeat sweep finds a stalled task | `modify({ proposedAction: "record" |
pre.tool.call | before an agent MCP tool call runs | block(reason) or modify({ args }) |
post.task.created, post.task.completed, post.task.failed, post.task.cancelled, post.task.superseded, post.task.progress | after the change is committed | none |
post.slack.message, post.tool.call | after the message or tool call finished | none |
post.chat.message | after a Chat SDK message is received (before buffering) or sent; direction is inbound or outbound | none |
post.approval.resolved | after a person answers an approval request, workflow or standalone (not on timeout or cancel) | none |
post.task.budgetRefused | after a task is refused by a daily spend budget | none |
post.email.received, post.kapso.message | after an inbound AgentMail or Kapso message is received | none |
post.vcs.event | after a GitHub, GitLab, or Azure DevOps webhook event; { provider, kind, action, repo, number?, ... } | none |
event.origin on pre.task.create tells where the task comes from: rest, app, mcp, slack, schedule, workflow, webhook, followUp, or extension:<name>.
Bundle shape
A predefined extension is a directory templates/extensions/<name>/ with exactly one manifest, manifest.yaml, manifest.yml or manifest.json, plus the files it references and a README.md. Only runtime: "api" is supported. Type the manifest with the generated JSON Schema, templates/extensions/manifest.schema.json:
# yaml-language-server: $schema=../manifest.schema.json
name: task-digest
description: Daily digest of completed and failed tasks
version: 1.0.0
runtime: api
assets:
hooks: hooks.ts
scripts:
- name: task-digest-collect # must start with "<name>-"
file: scripts/collect.ts
description: Count tasks finished in the last 24 hours
schedules:
- name: task-digest-daily # must start with "<name>-"
script: task-digest-collect # a script declared above
cronExpression: "0 9 * * *" # or intervalMs, exactly one
timezone: UTC
workflows:
- file: workflows/report.yaml # YAML or JSON; its `name` must start with "<name>-"
skills:
- dir: skills/task-digest-guide # SKILL.md + optional files/**; frontmatter `name` must start with "<name>-"
A workflow file holds name, optional description, definition, and optional triggers, cooldown, input, and triggerSchema. Install validates the definition. A swarm-script node can call the extension's own scripts. A skill directory holds only SKILL.md and files/**; each file under files/ becomes a skill file.
A JSON manifest carries "$schema": "../manifest.schema.json" instead. After changing a template, run bun run build:extension-catalog and commit src/extensions/catalog.generated.json.
Inline bundle
An inline install sends the same manifest as a JSON object, plus every file it references in files, keyed by bundle path:
{
"manifest": {
"name": "require-ticket-ref",
"description": "Blocks REST and MCP tasks that do not name a ticket",
"version": "1.0.0",
"runtime": "api",
"assets": { "hooks": "hooks.ts" }
},
"files": { "hooks.ts": "import { block, type SwarmExtension } from \"swarm-extension\";\n..." }
}
files must hold assets.hooks, each script file, each workflow file, and for each skill dir its SKILL.md plus any files/**. A file the manifest does not reference is rejected. Naming rules (<name>- prefixes, schedule scripts declared in assets.scripts) are the same as for a template. To change an inline extension, send the full bundle again under the same name; it adds a new version.
Rules for hooks.ts:
- Import only from
swarm-extension,zod, andstdlib. Relative imports, other packages, and dynamic imports are rejected. - Export the extension as
default. Exportconfig(a Zod schema) when the extension takes configuration. Activation validatesconfigagainst it. - Return
block(reason)ormodify(data)from pre hooks. Return nothing to continue. - Keep handlers fast. A handler is cancelled after 5 seconds. Five consecutive failures auto-disable the extension.
- Handlers run outside database transactions and must not assume ordering with other extensions. Use
priorityinapi.on(event, handler, { priority })when order matters (lower runs first).
Minimal hooks file
import { block, modify, type SwarmExtension } from "swarm-extension";
import { z } from "zod";
export const config = z.object({ pattern: z.string().default("DES-\\d+") });
const manifest = {
name: "require-ticket-ref",
description: "Blocks REST and MCP tasks that do not name a ticket",
version: "1.0.0",
runtime: "api",
assets: { hooks: "hooks.ts" },
config,
} as const;
const extension: SwarmExtension<typeof manifest> = (api) => {
api.on("pre.task.create", (event, ctx) => {
if (event.origin !== "rest" && event.origin !== "mcp") return;
if (new RegExp(ctx.config.pattern).test(event.description)) return;
return block(`Task must reference a ticket matching ${ctx.config.pattern}`);
});
api.on("post.task.completed", async (event, ctx) => {
await ctx.state.incr("completed");
ctx.log.info("task completed", { id: event.task.id });
});
};
export default extension;
Context (ctx)
ctx.config: the validated configuration.ctx.state: per-extension key-value store (get,set,incr,del).ctx.swarm: the swarm SDK. Call any script-exposed tool by name with underscores, for exampleawait ctx.swarm.slack_post({ channelId, message })orawait ctx.swarm.task_send({ ... }). Calls made from a hook carry theext:<name>identity, which has lead privileges while the extension is enabled. Calls resolve with{ success, status, data }and do not throw on a tool error. Checksuccessand throw when the hook must fail. The type definitions importSwarmSdkfromswarm-sdk. Call thescript-query-typestool (orGET /api/scripts/type-defs) to getswarm-sdk.d.tswith every method name and argument type before you usectx.swarm.ctx.log: structured logger. Entries appear in the extension run log on the dashboard.ctx.signal: abort signal for the 5-second cap.
Verify
- Call
extension-listand confirm the name, version, andenabled: false. The install response lists the assets it created. - Report the enable step. After a lead or operator enables it, the dashboard run log shows every dispatch with its result (
continue,modify,block, orerror). - Trigger the event once and confirm the effect (for example, a blocked REST task returns HTTP 422 with your reason).
Common mistakes
- Guessing event names. Fetch the type definitions.
- Using
importfrom a package other thanswarm-extension,zod, orstdlib. - Returning a plain object instead of
block(...)ormodify(...). - Modifying fields the event does not allow. Read the
*Modifytype for that event. - Expecting the extension to run after install. Its hooks, schedules, workflows, and skills are off until a lead or operator enables it.
- Expecting agents to see an extension skill after enable. Install it on each agent with
skill-install. - Sending an inline
manifest/filesbundle as a worker, or withoutEXTENSION_ALLOW_INLINE_INSTALLon. Workers use a catalogtemplate; with the flag off ask an operator to turn it on, or ship a catalog template. - Sending both
templateandmanifest/files. Pick one. - Blocking tasks from every origin. Check
event.originso schedules, workflows, and follow-ups keep working unless you mean to block them.