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. Custom tools can be written in TypeScript or Python.
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
├── SOUL.md
├── tools/ # Agent-level custom tools
│ ├── check_domain.ts
│ ├── analyze_text.py # Python tools supported too
│ ├── package.json # npm dependencies for TS tools
│ └── requirements.txt # pip dependencies for Python tools
├── files/ # Static assets available at /workspace/
│ └── data/
│ └── reference.csv
├── skills/
│ └── my-skill/
│ ├── SKILL.md
│ └── tools/ # Skill-scoped tools (loaded with the skill)
│ ├── analyze.ts
│ └── requirements.txt
└── .gitignoreTools 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. Omitting the skills array on an agent entry gives it access to all bundled skill tools.
Files in files/ are deployed to /workspace/ and available to tools at runtime.
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"),
}),Any Zod type that can be converted to JSON Schema is supported — common types include string, number, boolean, array, object, enum, literal, union, optional, default. The schema is converted to JSON Schema at deploy time (both Zod v3 and v4 are handled).
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 passed via stdin to the tool runner — the LLM never sees the values. They are not set in process.env during production runs. Secret values of 4+ characters are automatically scrubbed from tool output.
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 inside the tools/ directory (not the agent root):
json
{
"name": "my-agent-tools",
"private": true,
"type": "module",
"devDependencies": {
"swarmlord": "latest",
"zod": "^4.3.6"
}
}swarmlord and zod are always needed for TypeScript tools. Add any other packages your tools import — they'll be installed in the sandbox at runtime.
For Python tools, add a requirements.txt inside tools/:
requests
pydanticSkill-scoped tools use the same pattern: skills/<name>/tools/package.json or skills/<name>/tools/requirements.txt.
Preinstalled packages
The sandbox includes zod and sharp for TypeScript, and common Python libraries (numpy, pandas, matplotlib, pydantic, etc.). They work without declaring them, but listing them in your deps file is good practice.
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
Test tools locally without deploying using the swarmlord test command:
bash
swarmlord test check_domain -i '{"domain": "example.com"}' # inline input
swarmlord test check_domain -f test-input.json # file input
swarmlord test --list # list all tools
swarmlord test --all # run all with fixturesCreate .test.json fixtures alongside your tools for repeatable testing:
tools/
├── check_domain.ts
├── check_domain.test.json ← {"domain": "example.com"}
└── search_tlds.tsFor tools with secrets, set them as environment variables:
bash
SLACK_BOT_TOKEN=xoxb-test swarmlord test post_slack -i '{"channel": "#test", "text": "hello"}'Under the hood this uses the SWARMLORD_TOOL_SELFTEST=1 mode — you can also call it directly:
bash
echo '{"domain": "example.com"}' | SWARMLORD_TOOL_SELFTEST=1 bun tools/check_domain.tsSkill-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.
External skills and tools
When referencing external skills via skills: ["owner/repo/skill-name"] in swarmlord.jsonc, the tools/ directory from the remote repo is not pulled. Skill-scoped tools must live under your local skills/<name>/tools/ directory.
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) and .d.ts files are excluded from tool discovery and can be used for shared utilities.
- Deploy: The CLI typechecks TS tools and validates Python tools, extracts JSON schemas from Zod/Pydantic definitions, and bundles source into the deploy payload (max 50 MB).
- Runtime: The server runs the tool in the sandbox (
bunfor TS,python3for Python). Arguments are validated at runtime. The JSON Schema from deploy time is used for the model's tool definition. - Result: The handler's return string becomes the tool output the model sees.
Python Tools
Python tools use a @tool decorator from the swarmlord package:
python
from swarmlord import tool
from pydantic import BaseModel
class Input(BaseModel):
query: str
max_results: int = 5
@tool(
description="Search a database and return matching records.",
input=Input,
timeout=30000,
secrets=["DB_API_KEY"],
)
def handler(args, ctx):
ctx.log(f"Searching for: {args['query']}")
# args is a dict when using JSON Schema, or a Pydantic model instance when using BaseModel
# ctx.secrets is a dict with decrypted secret values
import json
return json.dumps({"results": []})Each Python tool file must have exactly one @tool-decorated function. The input can be a Pydantic BaseModel class or a JSON Schema dict. When using a BaseModel, the handler receives a validated model instance; when using a JSON Schema dict, it receives a plain dict. The ctx argument provides .log() and .secrets.
Self-test:
bash
echo '{"query": "test"}' | SWARMLORD_TOOL_SELFTEST=1 python3 tools/search_db.py