Swarm Scripts
Bulk, repeat, fan-out, or data-heavy work: write and run swarm scripts (inline `script-run`, named `script-upsert`, durable `launch-script-run`). Covers the script-vs-tool rubric, the authoring contract (`args` first, `ctx` second), the seed catalog, connections and secrets, files and bash over the agent-fs drive (`ctx.fs`, `ctx.sh`), `db_query`, and exposing a script as an API.
Template Content
Swarm Scripts
A swarm script is TypeScript that runs out of process with a typed Swarm SDK. Only its return value enters your context. Use one when direct tool calls would repeat, flood your context, or need deterministic processing over many records.
When to script
| Situation | Approach |
|---|---|
| 1 to 9 SDK calls, result fits in context | Direct tool calls. |
| 10 or more similar calls, bulk or fan-out | Script. Inline script-run for a one-off, a named script for reuse. |
| Fetch, parse, and transform heavy data | Script, or ctx_* (context-mode) when your harness has it. |
| One large web fetch | ctx_fetch_and_index (context-mode). |
| A schedule, workflow, or API script that reads or writes drive files | Script with ctx.fs and ctx.sh. |
| Multi-agent fan-out, parallel work, deterministic pipeline | Workflow. See workflow-iterate. |
| Work that repeats on a clock | Schedule. See scheduling. |
Named script only when the logic will run two or more times, by you, another agent, a schedule, or a workflow. A one-off goes inline, so the catalog does not fill with scratch saves.
Reference point: a workflow triage that took about 26 tool calls returns as one result of about 4k tokens in about 13 seconds.
Loading the tools
The script tools are deferred. Load them with your harness tool search before the first call: script-search, script-run, script-upsert, script-query-types, script-delete, and for durable runs launch-script-run, get-script-run, list-script-runs.
Run script-query-types before non-trivial work. It returns the live swarm-sdk.d.ts and stdlib declarations, including generated per-app types.
Seed catalog
The swarm ships named scripts at global scope. Each one replaces a multi-step tool chain. script-search with a plain-language description finds them. Call one with script-run and name plus args.
| Script | Args | Use |
|---|---|---|
task-context-gathering | { taskId, queries: [...] } | the task plus a deduplicated multi-query memory recall |
smart-recall | { queries: [...] } | multi-query memory recall without the task |
memory-dedup-check | { text, threshold? } | near-duplicates before you store a memory |
delegate | { agentName, task, routingReason, routingNote, parentTaskId? } | a subtask for an agent by name, returns { taskId } |
wait-for-task | { taskId } | waits up to about 25 s for a terminal state, returns { done, status, output }; call again while done is false |
get-child-outputs | { parentTaskId } | every child with status and output |
complete-task | { taskId, output } | finish a task from inside a script |
report-progress | { taskId, note } | a progress note from inside a script |
swarm-overview | {} | agents and task counts |
schedule-health, task-failure-audit, boot-triage | see heartbeat-runbook | the lead's heartbeat data gathering |
Authoring contract
The entry point takes args first and ctx second. A one-parameter function (ctx) receives args at runtime, so every ctx.* access throws. This is the most common cause of a failed run.
import type { ScriptContext } from "swarm-sdk";
import * as z from "zod";
export const argsSchema = z.object({ taskId: z.string(), limit: z.number().optional() });
export default async function (args: z.infer<typeof argsSchema>, ctx: ScriptContext) {
const res = await ctx.swarm.task_get({ taskId: args.taskId });
const task = ((res as { data?: unknown }).data ?? res) as { title?: string };
return { title: task?.title };
}
argscan beundefinedwhen a caller passes none. Guard withargsSchema.safeParse(args ?? {})or optional chaining.- Export a Zod
argsSchemafrom every named script.script-upsertconverts it to JSON Schema, so callers, schedules, and workflows see the input contract. - Both
zodandzod/miniimports work. Useimport * as z from "zod/mini"for smaller QuickJS bundles. Mini uses functional forms:z.optional(z.string()),z._default(z.number(), 10), andz.string().check(z.minLength(1)); its schemas still have.parse()and.safeParse(). - Inline source through
script-runruns without a typecheck.script-upserttypechecks before it saves. ImportScriptContextfrom"swarm-sdk"in inline code too, so promotion to a named script works. - SDK methods return
Promise<unknown>. Responses are usually wrapped: readres?.data ?? res. Exception:app_querywith a literalappIdandqueryreturns rows typed from the app's columns. agentIdis propagated through theX-Agent-IDheader, so SDK calls run as you.taskIdis not ambient: pass it inargswhen the script callstask_storeProgress.- A script invoked from a workflow node may run under a workflow identity.
- Return compact structured data. Raw logs, full HTML, big JSON arrays, and file contents stay inside the script.
- Limits: about 30 s wall clock (up to 5 minutes where the tool exposes it), 1 MB stdout. Never sleep or loop past about 25 s. Chain
wait-for-taskcalls instead. The cap also applies to a script called throughctx.step.swarmScript; the step'stimeoutMsdoes not extend it. A long LLM call, such as a 2,000-word draft, goes in actx.step.rawLlmstep insidelaunch-script-run, never in a called script. AtargetType: "script"schedule gets 60 s; a manualscript-rungets 30 s, so a candidate that finishes well under 30 s by hand has margin on the schedule. A nestedctx.swarm.script_rungets its own 30 s but runs inside its caller's remaining time: run slow children as sibling calls from the outer script, not deeper in the chain.
What ctx holds
ctx.swarm.*: the swarm SDK.task_get,task_send,task_storeProgress,task_action,task_list,slack_reply,memory_search,memory_store,kv_get,kv_getOrNull,kv_set,kv_delete,kv_incr,kv_list,swarm_get,agent_info,db_query, and more.kv_getOrNullreturns the entry,nullon a missing key, and throws on other errors.ctx.swarm.task_sendrequiresroutingReasonandroutingNotewheneveragentIdis present. The note must explain the worker choice in at least 10 characters after trimming whitespace (maximum 200 characters). Usehuman_pinnedfor an explicit/configured choice,skillfor a role or specialization match,continuityfor the same worker/session,reroute_faultfor a fault handoff, oroverflowfor capacity/pool escalation. Omit bothagentIdand routing fields for the pool.ctx.swarm.config:apiKey,agentId,mcpBaseUrl, andctx.swarm.config.get("KEY")for swarm config values, resolved for the agent running the script (agent scope over global, the same rowsget-configreturns for that agent). A key that is not set returnsundefined, soconfig.get("KEY") ?? fallbackworks. All areRedactedwrappers that stringify to<redacted>. Never unwrap one into a return value, a log line, or a request body you build by hand.ctx.api.<slug>andctx.mcp.<slug>: typed clients for registered connections. They exist only for registered connections. Introspect withObject.keys(ctx.api ?? {})andObject.keys(ctx.mcp ?? {}).ctx.fsandctx.sh: files and bash. See "Files and shell" below.ctx.stdlib:fetch,fetchJson(retries, 30 s timeout),grep,glob,table,Redacted.ctx.logger:log,warn,error. Keep logs short.
Files and shell
await ctx.fs.writeFile("/afs/reports/today.md", "# Today\n"); // your agent-fs drive
await ctx.sh("echo '- shipped' >> reports/today.md"); // cwd is /afs
const { stdout, exitCode } = await ctx.sh("grep -rn TODO /afs/notes | sort | head -20");
/afs/...is your default agent-fs drive, with your own permissions. It persists. Every other path (/tmp,/work) is memory that ends with the run. Relative paths resolve against/afs.ctx.fs:readFile,writeFile,appendFile,readdir,stat,exists,mkdir,rm,cp,mv. UTF-8 text only.ctx.sh(command, { cwd, env, stdin })returns{ stdout, stderr, exitCode }and does not throw on a failed command. CheckexitCode.ctx.shis just-bash, a bash interpreter in TypeScript: pipes, globs,grep,sed,awk,jq,sort,wc,find,xargs. Nogit,curl,node,python, or network. Files stay across calls in one run;cd,env, and variables do not.- Limits per run: 64 MiB of scratch files, about 30,000 scratch entries (8 MiB of names and entries), 64 MiB per command, and 64 MiB per
/afsfile read (EFBIG). Scratch names have at most 255 characters. - Without agent-fs for your agent, an
/afspath fails withagent-fs is not configured for this agent, inctx.fsand in shell redirects. Scratch andctx.shstill work. - Use
/afspaths directly. A scratch symlink into/afsfails (EXDEVwhen the drive is mounted). - A change that another agent makes to the drive can take up to 5 seconds to show in
lsorstatduring a run. Your own writes show at once.
Durable workflow scripts
launch-script-run runs a script as a durable, journaled run with a different ctx: ctx.run (id, agentId, args) and ctx.step.rawLlm(label, config), ctx.step.agentTask(label, config), ctx.step.swarmScript(label, config), plus ctx.swarm.*, ctx.stdlib, ctx.logger. Durable runs have no ctx.api, no ctx.mcp, no ctx.fs, no ctx.sh, and no ctx.swarm.config. A configured ctx.step.agentTask agentId records human_pinned unless routingReason is supplied. Call a connection or the drive from an inner script through ctx.step.swarmScript. See the script-workflows skill.
- Each
ctx.step.agentTaskstep is a normal worker task (taskType: "script-run-step"). When it finishes, the lead gets the usual "Worker task completed — review needed" follow-up, even while the run is still awaiting that step. The step config has nofollowUpConfigfield, so a value passed there is dropped. Let the script's own judge step be the gate and close those follow-ups with a one-line accept. - When a crash supersedes an in-flight step and resumes it as a new task, the run does not follow the new task id: it can stay
failed(for examplewrite EPIPE) while the resumed task completes. Check the resumed task's output before you re-launch the run.
Inline script pattern
import type { ScriptContext } from "swarm-sdk";
export default async function main(args: { status?: string; limit?: number } | undefined, ctx: ScriptContext) {
const res: any = await ctx.swarm.task_list({ status: args?.status, limit: args?.limit ?? 50 });
const tasks: any[] = res?.data?.tasks ?? res?.tasks ?? [];
ctx.logger.log(`Fetched ${tasks.length} tasks`);
return {
total: tasks.length,
tasks: tasks.map((task: any) => ({ id: task.id, status: task.status, title: task.task?.slice(0, 120) })),
};
}
Named script pattern
script-upsert with a searchable name, a concrete description, and an intent that says when to choose it. Good named scripts: aggregate failures by agent, schedule, or error family; fetch and normalize a third-party API response; fan out over tasks, memories, repos, or schedules; turn noisy JSON or HTML into a compact summary.
Secrets
A config row reaches your shell env only when its exportToEnv flag is on. If $KEY is empty in your shell but get-config or list-config shows the row marked not in agent env, the row stays server-side on purpose: do not copy the value into a file, an env var, or a command. Use it from a script instead, through one of the paths below. Harness, git, and agent-fs credentials stay exported. Keys only the API server reads (Slack app token and signing secret, GitHub App private key, webhook and OAuth client secrets) can never be exported. If a recipe genuinely needs the variable in a shell, ask the lead to turn exportToEnv on for that row (set-config with exportToEnv: true); it applies to your next task.
Order of preference:
- A registered connection:
ctx.api.<slug>.<operationId>({ path, query, header, body })(OpenAPI),ctx.api.<slug>.graphql(query, variables)(GraphQL),ctx.mcp.<slug>.<toolName>(args)(MCP, proxied server-side, returns the raw MCP envelope: readres.structuredContent ?? res.content?.[0]?.text). Credentials attach at egress. The script never sees them. OAuth tokens refresh on their own. - A credential binding placeholder in a hand-written request:
Authorization: Bearer [REDACTED:GITHUB_TOKEN]. The runtime substitutes the value at egress, only for the binding's allowed hosts. ctx.swarm.config.get("KEY")for a config value you pass toctx.stdlib.fetchas a header value. Unwrap it withctx.stdlib.Redacted.value(...)inside the header itself (a wrapped value sends the literal<redacted>), and never log it. Secret values are scrubbed from run output. Not available inlaunch-script-run(durable runs have noctx.swarm.config): collect there without secrets, then pass the run's output to a shortscript-runthat makes the authenticated calls.
A raw secret, or the output of get-config with includeSecrets: true, must not appear in script source, script args, a schedule's taskTemplate or scriptArgs, or a task description. Those are stored as plain text and replayed on every run.
Registration is lead-only: script-connections and credential-bindings. A worker that needs a missing connection puts the request in its task output: slug, baseUrl or spec URL, allowedHosts, and the auth (config key or OAuth provider). Leads: upsert-openapi with a spec URL keeps operations refreshable; credential-bindings oauth-app-upsert then oauth-authorize-url sets up an OAuth provider (tokenAuthStyle and tokenBodyFormat cover Notion-style token endpoints).
Full reference: the "Script connections" guide on the docs site.
db_query for aggregation
Direct SQL beats fetching lists into the script. The parameter is sql:
const res = await ctx.swarm.db_query({ sql: "SELECT status, count(*) AS cnt FROM agent_tasks GROUP BY status" });
db_query returns positional rows: { rows: unknown[][], columns: string[] }. Zip them:
function rowsToObjects(res: any): any[] {
const p = res?.data ?? res;
const cols: string[] = p?.columns ?? [];
return (p?.rows ?? []).map((r: any) =>
Array.isArray(r) ? Object.fromEntries(cols.map((c, i) => [c, r[i]])) : r,
);
}
Concurrency limit
ctx.swarm.db_query throttles at about three concurrent calls. A wider Promise.all fan-out can return 429 responses with no rows key, so res?.data?.rows ?? [] misreads throttling as a clean zero. Check the response status and data.error before reading rows. Serialize queries or cap concurrency at three.
Response traps
- Every
ctx.swarm.*response passes the secret scrubber. Text shaped like a secret-lookingkey=valueassignment comes back as[REDACTED:<key>], so code, HTML or profile text read throughkv_get,db_queryor a page read can differ from the stored bytes. Never write such a read back verbatim. Read stored text ashex(...)throughdb_query, decode it in the script, and refuse to publish or write when the result contains[REDACTED:. db_queryrejects any SQL containing;, even inside a string literal. Pass such literals throughparams.db_querycaps rows without an error (1000 in scripts). Aggregate in SQL and confirm totals withCOUNT(*).kv_getreturns the entry atdata: readres.data.value. The MCP tool shapeentry.valuereadsundefinedin a script.script_runreturns an envelope (result,truncated,durationMs,stdout,stderr,exitCode). The callee payload is at.result. Take key names from a real response: a guessed key that is absent reads as a false zero.- An
argsSchemavalidation error fires before the body runs and repeats on every retry. Fix the args instead of retrying. - The skill methods disagree on the id field:
skill_get({ id }), butskill_update({ skillId, content }).skill_update({ id, content })returns outersuccess: truewith innerdata.success: false("skillId is required.") and writes nothing. Gate a write onres.data.success, then re-read: the version must increase and the content must match the draft. - Write a dedup or idempotency marker only after the step succeeds. A marker written after a failure blocks the next real attempt.
kv_setreturnssuccess: falseinstead of throwing when a key has a character outside[a-zA-Z0-9._:/-](for example#or|from a git ref). Checksuccessafter every write.script_runwithoutscoperuns an agent-scoped row of the same name before the global one, so a stale private copy silently wins. Passscope: "global"for a shared script.- A nested
script_rundoes not overlap with the caller's ownctx.stdlib.fetchcalls: started alongside them it added about 8 s to one measured run. Start it after the caller's own queries.
Common tables: agent_tasks, session_logs, agent_memory, scheduled_tasks, agents. session_logs has no tool_name column. Tool names sit inside the content JSON column. Extract them with instr and substr in SQL, or parse the JSON in the script.
Progress from a script
export default async function main(args: { taskId: string; items: string[] }, ctx: ScriptContext) {
await ctx.swarm.task_storeProgress({ taskId: args.taskId, progress: `Processing ${args.items.length} items` });
return { processed: args.items.length };
}
Exposing a script as an API
A named script can serve POST /api/x/script/<id> for callers outside the swarm. Manage endpoints from the script's API tab in the dashboard, or with the script-apis tool (list, create, update, rotate, delete). list masks bearer tokens; includeSecrets: true reveals them. create and rotate return the plaintext token once.