Skip to content

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 dependencies

Tools 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)

PropertyDescription
ctx.log(...args)Log to the session stream (stderr). Does not appear in the tool's return value.
ctx.secretsTyped 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 Secret

Deploy-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 grants

Local testing: Self-test mode reads secrets from environment variables:

bash
SLACK_BOT_TOKEN=xoxb-test SWARMLORD_TOOL_SELFTEST=1 bun tools/post_slack.ts

timeout

Maximum execution time in milliseconds. The tool is killed if it exceeds this. Default: 60000 (60s).

typescript
timeout: 30_000,  // 30 seconds

retries

Number of automatic retries on failure. Default: 0 (no retries).

typescript
retries: 1,  // Retry once on failure

Dependencies

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.ts

This 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.ts

Skill 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.

  1. Deploy: The CLI typechecks tool files, extracts JSON schemas from the Zod definitions, and bundles the source code into the deploy payload.
  2. 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.)
  3. Result: The handler's return string becomes the tool output the model sees.