Extensions
Install trusted TypeScript hooks that change swarm orchestration at defined event boundaries.
Extensions let operators change swarm behavior without changing the API server source. Each extension is a bundle with a YAML or JSON manifest and the files it references. You install extensions by name from a predefined catalog. An operator can also allow inline installs, where the request carries the manifest and files.
A bundle has one hooks file and can also ship scripts, schedules, workflows, and skills.
Extension identities are internal execution principals. They do not appear in normal agent listings, status counts, or Slack rosters, and cannot receive task or schedule assignments.
Lead activation and trust
Extensions are trusted in-process code with no host isolation. A lead that can both install and activate a bundle has code execution with API-server-process privileges, including its filesystem, network, and secrets. Import checks, TypeScript checks, SDK permissions, and handler timeouts are not a sandbox.
EXTENSION_ALLOW_LEAD_ACTIVATION defaults to true to preserve autonomous lead
lifecycle control. Operators who do not accept that trust must set
EXTENSION_ALLOW_LEAD_ACTIVATION=false (or 0) in the API server deployment
environment and restart every API replica. This restores operator/dashboard-user-only
extension.activate access for enable, disable, and activate-version, across REST,
MCP, and the script SDK. It also denies lead-equivalent extension identities.
Any authenticated agent can install a draft, recorded under its createdByAgentId.
Workers can update and delete only their own extensions; enable, disable, and
activate-version remain denied to workers. Leads retain access to all bundles. This reserved key cannot be set through swarm_config, set-config,
or the dashboard; agents cannot turn the gate back on through those APIs.
The default accepts server-process execution by trusted leads as part of the requested lifecycle feature. Restricting activation to a lead's own bundles would not prevent execution of code that lead installed. The switch is an admission control, not containment or revocation: already-enabled code continues to run, including on restart, and existing code/configuration update paths remain available. To stop existing extensions, an operator must disable them; imported modules can retain side effects, so review the installation and restart the API as needed.
With permission auditing enabled, permission_audit records allow/deny decisions
for extension.activate with the calling agent in principalId. The batched
permission writer is best-effort and can be disabled via RBAC_AUDIT_DISABLED.
Agent lifecycle requests also write lifecycle.enable.requested,
lifecycle.disable.requested, or lifecycle.activate-version.requested to
extension_runs, with the calling agentId and target version, before lifecycle
side effects. These are request records, not success receipts; load failures get
separate load-error records. The run log retains only the newest 500 entries and
is deleted on uninstall. Neither log is tamper-proof against in-process code.
Ownership and drafts
Any authenticated agent may install an extension. Its createdByAgentId is the
installing agent and remains the owner across later versions. Each version records
its writer as changedByAgentId. GET responses and extension-list expose the
owner. The separate agentId is the extension's runtime identity.
Workers may stage versions of their own bundles and PATCH or DELETE their own disabled extensions. They cannot overwrite another owner's bundle. Leads, operators, and dashboard users retain access to all bundles.
New installs are disabled: validation typechecks the bundle without importing it, so the hooks stay inert. Bundled scripts are callable from install. Bundled schedules, workflows, and skills stay off until you enable the extension; see Bundled assets. Enabling and version activation require a lead, operator, or dashboard user. Workers may stage code-only versions of an enabled extension, but cannot PATCH it or change its live config/priority through reinstall.
Trust model
The installer accepts imports from swarm-extension, zod, and stdlib.
It rejects relative imports and other package imports.
It also rejects computed import() and require() expressions.
Bundle format
Each predefined extension is a directory under templates/extensions/.
The directory holds exactly one manifest.yaml, manifest.yml, or manifest.json, the files the manifest references, and a README.md.
The directory name must equal the manifest name.
This manifest from the task-digest template ships hooks, a script, a schedule, a workflow, and a skill:
# yaml-language-server: $schema=../manifest.schema.json
name: task-digest
description: Daily digest of tasks completed and failed in the last 24 hours
version: 1.0.0
runtime: api
assets:
hooks: hooks.ts
scripts:
- name: task-digest-collect
file: scripts/collect.ts
description: Count tasks completed and failed in the last 24 hours and summarize them
intent: Produce a daily digest of swarm task outcomes
schedules:
- name: task-digest-daily
description: Runs task-digest-collect every day at 09:00 UTC
script: task-digest-collect
cronExpression: "0 9 * * *"
timezone: UTC
args:
hours: 24
workflows:
- file: workflows/report.yaml
skills:
- dir: skills/task-digest-guideA JSON manifest sets "$schema": "../manifest.schema.json" instead of the comment.
templates/extensions/manifest.schema.json gives your editor completion and shape checks.
A script entry has name, file, description, and an optional intent that defaults to description.
A schedule entry has name, an optional description, script, exactly one of cronExpression or intervalMs, an optional timezone (default UTC), and optional args.
A workflow entry has file, the path of a YAML or JSON workflow file.
A skill entry has dir, a directory that holds SKILL.md and optional files/**.
A workflow file holds name, an optional description, definition, and optional triggers, cooldown, input, and triggerSchema.
This is workflows/report.yaml from task-digest:
name: task-digest-report
description: Collect the task digest on demand. Trigger it manually.
definition:
nodes:
- id: collect
type: swarm-script
label: Collect the digest
config:
scriptName: task-digest-collect
scope: global
args:
hours: 24A skill takes its name from the SKILL.md frontmatter.
Each file under the skill's files/ directory becomes a skill file, with its path relative to files/.
The skill directory can hold nothing else.
Install also enforces rules that the JSON Schema does not contain:
- Every script, schedule, workflow, and skill name starts with the extension name and a hyphen, such as
task-digest-. - A schedule's
scriptnames a script declared inassets.scripts. - A schedule sets exactly one of
cronExpressionorintervalMs. - Names do not repeat within a kind.
The install path validates the manifest, bundle paths, imports, and TypeScript types.
It also typechecks every script, checks every schedule's timing, validates every workflow definition, and parses every SKILL.md before it writes anything.
Each changed bundle creates an immutable version snapshot.
A lead, operator, or dashboard user can activate any stored version.
The hooks file exports one default SwarmExtension function.
It can also export a Zod schema named config.
The server validates stored configuration against that schema during activation.
Read responses scrub secrets from configJson.
The API stores the original values.
A PATCH request can submit unchanged [REDACTED:...] placeholders, including nested ones.
The server replaces those placeholders with the stored values before validation.
Contract
Register handlers with api.on(event, handler, options).
Lower priority numbers run first.
The stored extension priority applies when a handler omits its own priority.
import { modify, type SwarmExtension } from "swarm-extension";
import { z } from "zod";
export const config = z.object({ channelId: z.string(), agentId: z.string() });
const manifest = {
name: "route-channel-to-agent",
description: "Routes one Slack channel to a configured agent",
version: "1.0.0",
runtime: "api",
assets: { hooks: "hooks.ts" },
config,
} as const;
const extension: SwarmExtension<typeof manifest> = (api) => {
api.on("pre.slack.route", (event, ctx) => {
if (event.channelId !== ctx.config.channelId) return;
return modify({ target: { kind: "agent", agentId: ctx.config.agentId } });
});
};
export default extension;A pre.* handler can return one of these results:
undefinedor{ action: "continue" }{ action: "modify", data }{ action: "block", reason }
A post.* handler returns nothing.
Task post handlers observe committed changes.
Tool post handlers run after result finalization.
The Slack post event fires after authorization and before routing.
Version 1 events
| Event | Boundary | Event payload | Modify shape | Block effect |
|---|---|---|---|---|
pre.task.create | Task creation entry points | { options, description, origin, requestInfo? } | Input fields from CreateTaskOptions, plus description? | The caller receives a skipped result. No task is created. |
pre.task.followUp | Worker follow-up entry point | { completedTask, status, output?, failureReason?, workerAgentId, leadAgentId, summary } | { description?, agentId?, priority?, followUpConfig? } | No lead follow-up task is created. |
pre.slack.route | Slack message handler | { channelId, userId, text, threadTs?, botMentioned, threadContext? } | { target } for an agent, lead, or broadcast | The message creates no task or reply. |
pre.heartbeat.remediate | Stalled-task remediation | { task, session?, classification, proposedAction, reason, taskAgeMs, sessionHeartbeatAgeMs? } | { proposedAction } | The sweep records the finding but performs no remediation. |
pre.tool.call | Agent-facing MCP call | { tool, args, requestInfo } | { args } | The tool returns an error and does not run. |
post.task.created | Task event bus after commit | { task } | None | None |
post.task.completed | Task event bus after commit | { task, output } | None | None |
post.task.failed | Task event bus after commit | { task, failureReason } | None | None |
post.task.cancelled | Task event bus after commit | { task } | None | None |
post.task.superseded | Task event bus after commit | { task, supersededBy } | None | None |
post.task.progress | Task event bus after commit | { task, progress } | None | None |
post.slack.message | Slack message handler after authorization, before routing | { channelId, userId, text, threadTs?, taskId? } | None | None |
post.tool.call | Agent-facing MCP call after finalization | { tool, args, result, requestInfo, durationMs } | None | None |
post.approval.resolved | Approval respond route, after the answer commits | { requestId, status, responses, workflowRunId?, workflowRunStepId?, sourceTaskId? } | None | None |
post.task.budgetRefused | A task refused by a daily spend budget | { task, agentId, cause, agentSpendUsd?, agentBudgetUsd?, globalSpendUsd?, globalBudgetUsd?, userSpendUsd?, userBudgetUsd?, resetAt } | None | None |
post.email.received | AgentMail webhook, before routing | { inboxId, from, subject, body, threadId, messageId } | None | None |
post.kapso.message | Kapso inbound webhook, before routing | { phoneNumberId, conversationId?, messageId, from?, type?, text } | None | None |
post.vcs.event | GitHub, GitLab, and Azure DevOps webhooks | { provider, kind, action, repo, number?, title?, body?, author?, url?, merged?, changedFiles?, reviewState? } | None | None |
post.approval.resolved fires when a person answers an approval request, for workflow and standalone requests alike. workflowRunId and workflowRunStepId are absent for a standalone request. A request that times out or is cancelled does not fire it.
post.task.budgetRefused carries the refused task. cause is agent, global, or user, and the spend and budget fields for that cause are set. Like the other post.task.* events, it skips the extension that created the task.
post.email.received carries the first 500 characters of the body. post.kapso.message carries the text, or a placeholder naming the type for a non-text message.
post.vcs.event fires once per webhook event. kind is the provider's own object: pull_request, merge_request, issue, issue_comment, note, pull_request_review, or pipeline. action is the provider's own value, such as opened, synchronize, merge, or created; for a pipeline it is the status, such as success or failed. The event carries parsed fields, not the raw webhook body, and a field a kind does not have is absent.
pre.task.create origins include REST, apps, MCP, Slack, schedules, workflows, webhooks, follow-ups, and extensions.
Heartbeat classification values are no-session, stale-session, and fresh-stalled.
Heartbeat actions are supersede-resume, fail, and record.
Handler context
Each handler receives a context with these fields:
ctx.swarmexposes the script SDK through loopback HTTP. Calls resolve with{ success, status, data }and do not throw on a tool error. Theext:<name>identity acts with lead privileges while the extension is enabled, so hooks can post to Slack and use other lead-only tools. Method names follow the SDK (slack_post,task_send, ...). Thescript-query-typestool orGET /api/scripts/type-defsreturnsswarm-sdk.d.tswith the full surface.ctx.stateexposes extension-scopedget,set,incr, anddeloperations.ctx.configcontains the validated operator configuration.ctx.logwrites scrubbed log messages.ctx.signalaborts when the handler reaches its time limit.ctx.eventidentifies the event, extension, version, and dispatch time.
The extension runs as the system agent ext:<name>.
Its ctx.swarm calls use callOrigin: "extension", authenticated by a per-process token that only the in-process SDK holds.
These calls bypass pre.tool.call to prevent recursion.
Tasks created by an extension also skip that extension's own handlers.
Ordering and failures
Handlers run in ascending priority order. Handlers with equal priority run by extension name. Each valid modification becomes the next handler's input. The first block result stops the chain.
The dispatcher rejects pre.* dispatch inside a database transaction.
It logs the violation and continues without calling handlers.
A throw, invalid result, invalid modification, or timeout fails open.
The default timeout is five seconds.
The dispatcher records the failure and continues the operation.
A successful handler resets that extension's consecutive failure count.
Five consecutive failures set the extension status to auto-disabled.
Auto-disable also pauses the extension's schedules, workflows, and skills, the same way an explicit disable does.
A lead, operator, or dashboard user must enable it again, which restores them.
Install and operate
The catalog is the only install source.
List it with GET /api/extensions/catalog or the extension-catalog MCP tool.
Each entry shows the template's asset counts, its README, and whether it is installed.
In the dashboard, open Settings, Extensions, and select Install extension to reach the catalog page at /settings/extensions/new.
Install a template by name.
The body accepts only template, priority, and config:
curl "$API/api/extensions/catalog" \
-H "Authorization: Bearer $KEY"
curl -X POST "$API/api/extensions/install" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{"template":"task-digest"}'
curl -X POST "$API/api/extensions/<id>/enable" \
-H "Authorization: Bearer $KEY"
curl "$API/api/extensions/<id>/runs" \
-H "Authorization: Bearer $KEY"
curl -X POST "$API/api/extensions/<id>/activate-version" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{"version":1}'A body with manifest or files returns 400 inline_install_disabled unless inline install is on.
An unknown template returns 404 extension_template_not_found.
The install response includes assets with the created, updated, skipped, deleted, and detached assets.
assets is null when the installed version is staged, not active.
If a script, schedule, workflow, or skill with the same name already exists outside this extension, install fails and writes nothing.
The skill check covers skills in every scope.
The Settings page provides install, edit, activation, version, and run-log controls.
Agents can use the extension-catalog, extension-install, extension-list, extension-enable, extension-disable, extension-activate-version, and extension-delete MCP tools.
The script SDK exposes the same names with underscores, such as ctx.swarm.extension_enable({ id }).
Enable, disable, and activate-version require a lead, operator, or dashboard user. Workers can create extensions and update or delete only their own disabled drafts. Delete refuses enabled extensions; an authorized caller must disable them first.
The MCP install tool never activates a version.
Its input is { template, priority?, config? }, or { manifest, files, priority?, config? } for an inline bundle.
Extensions installed from inline bundles before the catalog keep loading, enabling, disabling, and uninstalling. They have no bundled assets. They can receive new inline versions while inline install is on. Installing a catalog template with the same name adds a version to the existing extension.
Inline install
By default an extension installs only from the catalog, which needs a pull request and a deploy.
Set EXTENSION_ALLOW_INLINE_INSTALL=true to also accept an inline bundle, so a lead can create an extension directly.
The key is off by default.
Set it under Settings > Configuration > Security & access or in the API environment.
It is read on every request, so a change needs no restart.
curl -X POST "$API/api/extensions/install" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{
"manifest": {
"name": "require-ticket-ref",
"description": "Blocks 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..." }
}'manifestandfilesgo together and excludetemplate.filesholds every path the manifest references, with no extras.- Flag off: every inline body returns 400
inline_install_disabled. - Flag on: only lead agents, the operator, and dashboard users can send an inline bundle. A worker gets 403 and stays catalog-only. The check is the
extension.install.inlinepermission. - Validation, asset creation, ownership, and version staging match a catalog install. The extension lands disabled. Enabling it uses the existing enable path and its
extension.activatecheck. - Agents use the
extension-installMCP tool withmanifestandfiles.
The flag is an ordinary configuration key, so a lead can turn it on with set-config.
It is not reserved like EXTENSION_ALLOW_LEAD_ACTIVATION.
To keep a human in the loop, also set EXTENSION_ALLOW_LEAD_ACTIVATION=false: a lead can then stage an inline bundle, but only an operator or dashboard user can enable it.
Bundled assets
Install writes the assets in the same transaction as the extension, when the installed version is active.
It writes scripts first, so schedules and workflow swarm-script nodes can call them.
Every asset belongs to the ext:<name> agent.
- Scripts are global. They have no enabled state: they are callable from install, whether or not the extension is enabled.
- Schedules run their script as the
ext:<name>agent. - Workflows are created disabled. A disabled workflow ignores its triggers and refuses manual triggers.
- Skills are created disabled with global scope.
Install extension skills on agents
An extension skill is global, not swarm-wide.
A swarm-scope skill reaches every agent.
A global skill reaches an agent only when it is installed on that agent and enabled.
The extension never installs its skills on agents.
To use one, enable the extension, then install the skill on each agent with the skill-install tool.
skill-install refuses a disabled skill, so enable the extension first.
Asset lifecycle
| Action | Scripts | Schedules, workflows, and skills |
|---|---|---|
| Install | Created and callable at once | Created disabled |
| Enable | No change | The first enable turns them on. Later enables restore the state recorded at disable, so an asset you turned off stays off. |
| Disable | No change | Turned off |
| Upgrade | Unedited scripts update. Edited scripts stay and report as skipped. | Same rule |
| Upgrade drops the asset | Deleted if unedited, detached if edited | Same rule |
| Uninstall | Deleted if unedited, detached if edited | Same rule |
An upgrade is a version activation, or an install that activates the new version.
A detached asset stays in place, but the extension no longer tracks it.
Uninstall works only on a disabled extension.
It removes skills first, then workflows, schedules, and scripts.
DELETE /api/extensions/{id} returns { deleted: true, assets: { deleted, detached } }.
Example hooks
These examples come from the extension test fixtures.
Rewrite task priority
import { modify, type SwarmExtension } from "swarm-extension";
const extension: SwarmExtension = (api) => {
api.on("pre.task.create", () => modify({ priority: 1 }));
};
export default extension;Suppress Slack follow-ups
import { block, type SwarmExtension } from "swarm-extension";
const extension: SwarmExtension = (api) => {
api.on("pre.task.followUp", (event) => {
if (event.completedTask.source === "slack") {
return block("Slack tasks report in the original thread");
}
});
};
export default extension;Route a Slack channel
import { modify, type SwarmExtension } from "swarm-extension";
import { z } from "zod";
export const config = z.object({ channelId: z.string(), agentId: z.string() });
const manifest = {
name: "route-channel-to-agent",
description: "Routes one Slack channel to a configured agent",
version: "1.0.0",
runtime: "api",
assets: { hooks: "hooks.ts" },
config,
} as const;
const extension: SwarmExtension<typeof manifest> = (api) => {
api.on("pre.slack.route", (event, ctx) => {
if (event.channelId !== ctx.config.channelId) return;
return modify({ target: { kind: "agent", agentId: ctx.config.agentId } });
});
api.on("post.slack.message", async (event, ctx) => {
await ctx.state.set(`slack:${event.channelId}`, event.userId);
});
};
export default extension;Record instead of failing long tasks
import { modify, type SwarmExtension } from "swarm-extension";
const extension: SwarmExtension = (api) => {
api.on("pre.heartbeat.remediate", (event) => {
if (event.proposedAction === "fail") {
return modify({ proposedAction: "record" });
}
});
};
export default extension;Reject exclamation marks
import { block, type SwarmExtension } from "swarm-extension";
const extension: SwarmExtension = (api) => {
api.on("pre.tool.call", (event) => {
if (event.tool !== "store-progress") return;
if (!event.args || typeof event.args !== "object") return;
const progress = (event.args as { progress?: unknown }).progress;
if (typeof progress === "string" && progress.includes("!")) {
return block("Progress text must not contain exclamation marks.");
}
});
api.on("post.tool.call", async (event, ctx) => {
await ctx.state.set("last-post", {
tool: event.tool,
args: event.args,
result: event.result,
durationMs: event.durationMs,
});
});
};
export default extension;Version 1 limits
- Only API runtime hooks can run.
- Extensions install from the catalog. Inline bundles are rejected unless
EXTENSION_ALLOW_INLINE_INSTALLis on. - Assets are hooks, scripts, schedules, workflows, and skills. Every bundle needs a
hooksfile. - Extensions cannot ship connections, config defaults, KV seeds, pages, apps, prompt templates, task templates, or
requires.repos. - Manifests have no
lifecycle.setup,lifecycle.teardown, orpermissions. - There is no marketplace or remote install.
- Schedules can target only a script.
- Skills are never swarm-wide, and the extension does not install them on agents.
- The dashboard does not show upgrade drift. Skipped assets appear only in the API response.
- Scheduled script runs do not check the state of the extension's hooks.
- Tool events cover agent-facing MCP calls only.
- Slack route events cover the Slack message handler only.
- Heartbeat hooks can change remediation only after the sweep finds a stalled task.
- The loader runs in one API process. Other replicas can remain stale for 30 seconds.
- Disposal removes temporary source files. Bun retains imported modules in its registry.
- A failed PATCH reload leaves the extension enabled with
status: "error"until the next enable request.
Use the contributor runbook for implementation rules.