Custom Tools
Built-in tools cover file I/O, shell, web search, and browser automation. When your agent needs to call an API, process domain-specific data, or interact with a service, write a custom tool.
Quick Example
Create tools/check_domain.ts in your agent directory:
typescript
import { tool } from "swarmlord/tool";
import { z } from "zod";
export default tool({
description: "Check if a domain name is available using RDAP lookup.",
input: z.object({
domain: z.string().describe("Full domain to check, e.g. coolstartup.com"),
}),
handler: async ({ domain }, ctx) => {
ctx.log(`Checking ${domain}...`);
const res = await fetch(`https://rdap.org/domain/${domain}`);
if (res.status === 404) {
return JSON.stringify({ domain, available: true });
}
return JSON.stringify({ domain, available: false });
},
});Deploy the agent and the tool appears in the agent's tool list automatically.
Project Structure
my-agent/
├── swarmlord.jsonc
├── AGENTS.md
├── tools/ # Agent-level custom tools
│ ├── check_domain.ts
│ └── search_tlds.ts
├── skills/
│ └── my-skill/
│ ├── SKILL.md
│ └── tools/ # Skill-scoped tools (loaded with the skill)
│ └── analyze.ts
└── package.json # Only needed if tools have dependenciesTools in tools/ are always available. Tools in skills/<name>/tools/ are available based on the agent's skills configuration in swarmlord.jsonc. In multi-agent setups, each sub-agent entry's skills array determines which skill tools it has access to.
Tool API
Every tool file must have a default export using the tool() helper from swarmlord/tool:
typescript
import { tool } from "swarmlord/tool";
import { z } from "zod";
export default tool({
description: string, // Shown to the model — be specific about what it does and when to use it
input: z.object({ ... }), // Zod schema — field .describe() strings help the model fill parameters
handler: async (args, ctx) => string, // Must return a string (typically JSON.stringify)
timeout?: number, // Execution timeout in ms (default: 60000)
retries?: number, // Auto-retries on failure (default: 0)
});description
The model reads this to decide when to call the tool. Be specific:
typescript
// Good — tells the model what, when, and limits
description:
"Check if a specific domain name is currently registered using RDAP lookup. " +
"Returns availability status and registrar info if taken.",
// Bad — vague
description: "Check a domain",input (Zod Schema)
Define parameters with Zod. Use .describe() on every field — these descriptions appear in the tool's JSON Schema and guide the model:
typescript
input: z.object({
url: z.string().describe("Full URL of the page to check, e.g. https://example.com"),
depth: z
.number()
.optional()
.describe("How many links deep to crawl. Defaults to 1 (current page only)"),
tlds: z
.array(z.string())
.optional()
.describe("TLD extensions to check. Defaults to: com, io, dev, app"),
}),Supported Zod types: string, number, boolean, array, object, enum, literal, union, optional, default. The schema is converted to JSON Schema at deploy time.
handler
Receives parsed args (typed via Zod inference) and a context object. Must return a string — use JSON.stringify() for structured data:
typescript
handler: async ({ url, depth }, ctx) => {
ctx.log(`Crawling ${url} to depth ${depth ?? 1}...`);
const result = await crawl(url, depth);
return JSON.stringify({
pages: result.pages.length,
errors: result.errors,
summary: result.summary,
});
},ctx (ToolContext)
| Property | Description |
|---|---|
ctx.log(...args) | Log to the session stream (stderr). Does not appear in the tool's return value. |
ctx.secrets | Typed record of decrypted secret values declared in the tool's secrets array. |
secrets
Declare the secret names your tool needs. At runtime they are decrypted and injected directly into ctx.secrets inside the tool handler — the LLM never sees the values. They also never appear in process.env, command strings, /proc, or session logs.
typescript
import { tool } from "swarmlord/tool";
import { z } from "zod";
export default tool({
description: "Post a message to a Slack channel.",
input: z.object({
channel: z.string(),
text: z.string(),
}),
secrets: ["SLACK_BOT_TOKEN"] as const,
handler: async ({ channel, text }, ctx) => {
const res = await fetch("https://slack.com/api/chat.postMessage", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${ctx.secrets.SLACK_BOT_TOKEN}`,
},
body: JSON.stringify({ channel, text }),
});
return JSON.stringify(await res.json());
},
});Use as const on the secrets array to get type-safe access to ctx.secrets.SLACK_BOT_TOKEN (TypeScript will error on ctx.secrets.NONEXISTENT).
Setting secrets:
bash
# Via CLI (reads value from stdin or prompts interactively)
echo "xoxb-..." | swarmlord secret put SLACK_BOT_TOKEN
swarmlord secret list
swarmlord secret delete SLACK_BOT_TOKEN
# Or via the dashboard: Settings → Secrets → Add SecretDeploy-time validation: If a tool declares secrets that haven't been configured, deployment will fail with a clear error message listing the missing keys.
Per-agent grants: Each agent can only access secrets that have been explicitly granted to it. By default, swarmlord deploy auto-grants any declared secrets to the agent. Use --strict to require explicit grants instead:
bash
# Auto-grant (default): deploy and automatically grant declared secrets
swarmlord deploy
# Strict mode: fail if grants are missing
swarmlord deploy --strict
# Manually grant/revoke secrets
swarmlord secret grant my-agent SLACK_BOT_TOKEN OPENAI_KEY
swarmlord secret revoke my-agent SLACK_BOT_TOKEN
# List grants for an agent (or all agents)
swarmlord secret grants my-agent
swarmlord secret grantsLocal testing: Self-test mode reads secrets from environment variables:
bash
SLACK_BOT_TOKEN=xoxb-test SWARMLORD_TOOL_SELFTEST=1 bun tools/post_slack.tstimeout
Maximum execution time in milliseconds. The tool is killed if it exceeds this. Default: 60000 (60s).
typescript
timeout: 30_000, // 30 secondsretries
Number of automatic retries on failure. Default: 0 (no retries).
typescript
retries: 1, // Retry once on failureDependencies
If your tools need npm packages, add a package.json to the agent directory:
json
{
"name": "my-agent",
"private": true,
"type": "module",
"devDependencies": {
"swarmlord": "latest",
"zod": "^4.3.6"
}
}swarmlord and zod are always needed. Add any other packages your tools import — they'll be installed in the sandbox at runtime.
WARNING
Tools run inside the agent's sandbox (a Linux container). They have network access and can use fetch, but cannot access the host filesystem. Dynamic imports like await import("sharp") work if the package is installed.
Patterns
HTTP API calls
The most common pattern — call an external API and return structured results:
typescript
export default tool({
description: "Look up current weather for a city.",
input: z.object({
city: z.string().describe("City name, e.g. San Francisco"),
}),
timeout: 15_000,
handler: async ({ city }) => {
const res = await fetch(`https://wttr.in/${encodeURIComponent(city)}?format=j1`);
if (!res.ok) {
return JSON.stringify({ error: `Weather API returned ${res.status}` });
}
const data = await res.json();
return JSON.stringify({
city,
temp_c: data.current_condition[0].temp_C,
description: data.current_condition[0].weatherDesc[0].value,
});
},
});Batch operations with progress logging
For tools that process many items, use ctx.log to stream progress and batch requests to avoid rate limits:
typescript
export default tool({
description: "Check availability of a domain across multiple TLDs.",
input: z.object({
name: z.string().describe("Base name without TLD, e.g. 'coolstartup'"),
tlds: z.array(z.string()).optional().describe("TLDs to check. Defaults to: com, io, dev, app, co, ai"),
}),
timeout: 45_000,
retries: 1,
handler: async ({ name, tlds }, ctx) => {
const checkTlds = tlds ?? ["com", "io", "dev", "app", "co", "ai"];
const results = [];
const BATCH_SIZE = 8;
for (let i = 0; i < checkTlds.length; i += BATCH_SIZE) {
const batch = checkTlds.slice(i, i + BATCH_SIZE);
const batchResults = await Promise.all(
batch.map(async tld => {
const domain = `${name}.${tld}`;
const res = await fetch(`https://rdap.org/domain/${domain}`);
return {
domain,
tld,
available: res.status === 404,
};
})
);
results.push(...batchResults);
if (i + BATCH_SIZE < checkTlds.length) {
ctx.log(`Checked ${results.length}/${checkTlds.length} TLDs...`);
await new Promise(r => setTimeout(r, 1500));
}
}
return JSON.stringify(results);
},
});File processing
Tools can read and write files in the sandbox. Use dynamic imports for heavy libraries:
typescript
export default tool({
description: "Read image metadata: dimensions, format, channels, file size.",
input: z.object({
source: z.string().describe("Absolute path to the image, e.g. /workspace/photo.jpg"),
}),
timeout: 15_000,
handler: async ({ source }) => {
const sharp = (await import("sharp")).default;
const { statSync } = await import("fs");
const meta = await sharp(source).metadata();
const stats = statSync(source);
return JSON.stringify({
width: meta.width,
height: meta.height,
format: meta.format,
channels: meta.channels,
hasAlpha: meta.hasAlpha ?? false,
fileSize: stats.size,
});
},
});Error handling
Return error information as structured data rather than throwing — this gives the model actionable feedback:
typescript
handler: async ({ url }, ctx) => {
try {
const res = await fetch(url, { redirect: "follow" });
if (!res.ok) {
return JSON.stringify({
url,
error: `HTTP ${res.status}`,
suggestion: res.status === 403
? "Site blocks automated requests"
: "Try again or check the URL",
});
}
const data = await res.text();
return JSON.stringify({ url, content: data.slice(0, 5000) });
} catch (e) {
return JSON.stringify({
url,
error: e instanceof Error ? e.message : String(e),
});
}
},Local Testing
Tools support self-testing via stdin when SWARMLORD_TOOL_SELFTEST=1 is set:
bash
echo '{"domain": "example.com"}' | SWARMLORD_TOOL_SELFTEST=1 bun tools/check_domain.tsThis parses the JSON input through the Zod schema, runs the handler, and prints the result. Useful for quick iteration before deploying.
Skill-Scoped Tools
Tools inside skills/<name>/tools/ are registered at deploy time based on the agent's skills configuration in swarmlord.jsonc. In multi-agent setups, each sub-agent entry's skills array determines which skill tools it has access to. The skill() tool call at runtime only loads the SKILL.md content — it does not gate access to skill tools.
skills/
└── instagram-preview/
├── SKILL.md
└── tools/
├── generate_preview.ts
└── apply_filters.tsSkill tools follow the same tool() + default export pattern. The SKILL.md frontmatter name must match the directory name.
How It Works Under the Hood
Reserved names and excluded files
Custom tool filenames cannot collide with built-in tool names: bash, read, write, edit, glob, grep, task, todoread, todowrite, webfetch, websearch, batch, browser, skill. Files starting with _ (e.g. _helpers.ts) are excluded from tool discovery and can be used for shared utilities.
- Deploy: The CLI typechecks tool files, extracts JSON schemas from the Zod definitions, and bundles the source code into the deploy payload.
- Runtime: When the agent calls a custom tool, the server runs the tool source inside the sandbox using
bun. Arguments are validated through the Zod schema at runtime inside the sandbox. (The JSON Schema extracted at deploy time is used for the model's tool definition, not for runtime validation.) - Result: The handler's return string becomes the tool output the model sees.