Skip to content

Writing a Custom Tool

Alfred exposes tools to the model via the Tool interface in src/tools/types.ts. The buildTool() factory fills safe defaults for every optional method, so you only implement what you need.

The Tool interface

ts
interface Tool<In extends z.ZodTypeAny, Out> {
  name:            string;
  description:     string;
  inputSchema:     In;

  call(input: z.output<In>, ctx: ToolContext): Promise<ToolResult<Out>>;

  isEnabled(): boolean;
  isReadOnly(input: z.output<In>): boolean;
  isConcurrencySafe(input: z.output<In>): boolean;
  checkPermissions(
    input: z.output<In>,
    ctx: ToolPermissionContext,
  ): Promise<PermissionResult>;
  describeCall(input: z.output<In>): string;
  render(result: ToolResult<Out>): readonly ContentBlock[];
}

buildTool() supplies these defaults when you omit them:

MethodDefault
isEnabled() => true
isReadOnly() => false — conservative; write-unsafe
isConcurrencySafe() => false — runs serially
checkPermissionsasync () => allow() — always allow
describeCall() => tool.name
renderserialises content as a text block

Step 1 — Define the input schema with Zod

ts
import { z } from "zod";

const inputSchema = z.object({
  query:   z.string().describe("Search query"),
  limit:   z.number().int().positive().max(50).default(10)
            .describe("Maximum results to return"),
  include: z.array(z.string()).optional()
            .describe("File globs to include"),
});

The Zod schema serves triple duty: it validates incoming model-supplied JSON, generates the JSON Schema that is sent to the model in the tool definition, and narrows the TypeScript type of input inside call().

Step 2 — Declare capability flags

Capability flags let the engine schedule tools efficiently without guessing:

ts
isReadOnly: () => true,        // safe to run in parallel; no filesystem writes
isConcurrencySafe: () => true, // safe to interleave with other read-only calls

Read-only AND concurrency-safe tools run in parallel. Any other combination serialises. When in doubt, leave both as false (the default) — it is safe but slower.

Step 3 — Implement checkPermissions

Return one of allow(), ask(reason), or deny(reason) from src/permissions/types.ts:

ts
import { allow, ask, deny } from "../permissions/types.ts";
import type { ToolPermissionContext } from "../permissions/types.ts";

checkPermissions: async (input, ctx: ToolPermissionContext) => {
  // Hard deny: never allow writes outside the workspace
  if (!input.path.startsWith(ctx.workingDir)) {
    return deny("path escapes the workspace");
  }
  // Ask the user unless they chose bypass/acceptEdits
  if (ctx.mode === "default" || ctx.mode === "plan") {
    return ask(`search ${input.query} in ${input.path}`);
  }
  return allow();
},

ctx.mode is one of "default" | "acceptEdits" | "plan" | "bypass".

Step 4 — Implement call

ts
import type { ToolResult, ToolContext } from "./types.ts";

call: async (input, ctx: ToolContext): Promise<ToolResult<string>> => {
  // ctx.workingDir  — absolute project root
  // ctx.signal      — AbortSignal (respect it in long-running ops)
  // ctx.readFileState — last-read snapshot map for read-before-write checks
  // ctx.permissions — ToolPermissionContext (same as checkPermissions receives)

  try {
    const results = await mySearchLibrary(input.query, {
      cwd:    ctx.workingDir,
      limit:  input.limit,
      signal: ctx.signal,
    });
    return { content: results.join("\n") };
  } catch (err) {
    return {
      content: `Search failed: ${err instanceof Error ? err.message : String(err)}`,
      isError: true,
    };
  }
},

Always return { content, isError: true } for errors rather than throwing. This surfaces the failure to the model so it can retry or change approach.

Marking untrusted content

If your tool fetches content from external sources (web pages, third-party APIs) mark the result untrusted: true. Alfred uses this flag for taint tracking (ADR 0003):

ts
return { content: fetchedHtml, untrusted: true };

Step 5 — Assemble the tool with buildTool

ts
import { z } from "zod";
import { buildTool } from "./types.ts";
import { allow, ask, deny } from "../permissions/types.ts";

const inputSchema = z.object({
  query: z.string().describe("Ripgrep pattern"),
  path:  z.string().optional().describe("Directory to search (default: workspace root)"),
});

export const ripgrepTool = buildTool({
  name:        "ripgrep",
  description: "Fast regex search via rg. Returns file:line:match lines.",
  inputSchema,

  isReadOnly:        () => true,
  isConcurrencySafe: () => true,

  describeCall: (input) => `ripgrep(${input.query})`,

  checkPermissions: async (_input, _ctx) => allow(),

  call: async (input, ctx) => {
    const cwd  = input.path ?? ctx.workingDir;
    const proc = Bun.spawn(
      ["rg", "--line-number", "--max-count=200", input.query, cwd],
      { stdout: "pipe", stderr: "pipe", signal: ctx.signal },
    );

    const [out, err] = await Promise.all([
      new Response(proc.stdout).text(),
      new Response(proc.stderr).text(),
    ]);
    await proc.exited;

    if (proc.exitCode === 0) return { content: out.trim() || "(no matches)" };
    if (proc.exitCode === 1) return { content: "(no matches)" };
    return { content: `rg error: ${err.trim()}`, isError: true };
  },
});

Step 6 — Register the tool in src/tools/index.ts

Open src/tools/index.ts and add your tool to the BUILTIN array:

ts
import { ripgrepTool } from "./ripgrep.ts";   // ← add this import

const BUILTIN: readonly Tool[] = [
  fileReadTool,
  fileWriteTool,
  fileEditTool,
  bashTool,
  globTool,
  grepTool,
  memorySearchTool,
  memoryUpsertTool,
  memoryForgetTool,
  webFetchTool,
  skillTool,
  ripgrepTool,   // ← add here
];

getAllTools() filters by isEnabled(), so a tool can gate itself on environment conditions without being removed from the list.

Complete example — src/tools/ripgrep.ts

ts
import { z } from "zod";
import { buildTool } from "./types.ts";
import type { ToolResult, ToolContext } from "./types.ts";
import { allow } from "../permissions/types.ts";

const inputSchema = z.object({
  query:     z.string().describe("Regex pattern passed to rg"),
  path:      z.string().optional().describe("Root directory (default: workspace)"),
  maxCount:  z.number().int().positive().max(500).default(200)
              .describe("Cap on returned matches"),
  ignoreCase: z.boolean().optional().describe("Case-insensitive search"),
});

export const ripgrepTool = buildTool({
  name: "ripgrep",
  description:
    "Fast recursive regex search via the rg binary. " +
    "Returns file:line:match lines, capped at maxCount.",
  inputSchema,

  isEnabled:         () => true,
  isReadOnly:        () => true,
  isConcurrencySafe: () => true,

  checkPermissions: async () => allow(),

  describeCall: (input) =>
    `ripgrep(${input.query}${input.path ? ` in ${input.path}` : ""})`,

  call: async (input, ctx: ToolContext): Promise<ToolResult<string>> => {
    const searchRoot = input.path ?? ctx.workingDir;
    const args: string[] = [
      "--line-number",
      `--max-count=${input.maxCount}`,
      ...(input.ignoreCase ? ["--ignore-case"] : []),
      input.query,
      searchRoot,
    ];

    const proc = Bun.spawn(["rg", ...args], {
      stdout: "pipe",
      stderr: "pipe",
    });

    // Respect AbortSignal for cancellation.
    const onAbort = () => proc.kill();
    ctx.signal.addEventListener("abort", onAbort, { once: true });

    const [stdout, stderr] = await Promise.all([
      new Response(proc.stdout).text().catch(() => ""),
      new Response(proc.stderr).text().catch(() => ""),
    ]);
    await proc.exited;
    ctx.signal.removeEventListener("abort", onAbort);

    switch (proc.exitCode) {
      case 0:  return { content: stdout.trim() || "(no matches)" };
      case 1:  return { content: "(no matches)" };
      default: return {
        content: `rg exited ${proc.exitCode}: ${stderr.trim()}`,
        isError: true,
      };
    }
  },
});

Testing your tool

Write the test before the implementation (see the TDD workflow in the global rules). Here is a minimal test using Bun's built-in test runner:

ts
// tests/ripgrep.test.ts
import { describe, it, expect } from "bun:test";
import { ripgrepTool } from "../src/tools/ripgrep.ts";
import type { ToolContext } from "../src/tools/types.ts";

const ctx: ToolContext = {
  workingDir:    process.cwd(),
  signal:        new AbortController().signal,
  readFileState: new Map(),
  permissions: {
    mode:         "bypass",
    allowedTools: new Set(),
    deniedTools:  new Set(),
    workingDir:   process.cwd(),
  },
};

describe("ripgrepTool", () => {
  it("returns matches for a known pattern", async () => {
    const result = await ripgrepTool.call(
      { query: "buildTool", maxCount: 200 },
      ctx,
    );
    expect(result.isError).toBeFalsy();
    expect(result.content).toContain("buildTool");
  });

  it("returns no-matches sentinel on zero results", async () => {
    const result = await ripgrepTool.call(
      { query: "PATTERN_THAT_DEFINITELY_DOES_NOT_EXIST_xyz123" },
      ctx,
    );
    expect(result.isError).toBeFalsy();
    expect(result.content).toBe("(no matches)");
  });

  it("is read-only and concurrency-safe", () => {
    expect(ripgrepTool.isReadOnly({ query: "x" })).toBe(true);
    expect(ripgrepTool.isConcurrencySafe({ query: "x" })).toBe(true);
  });
});
bash
bun test tests/ripgrep.test.ts

isEnabled for optional tools

If your tool depends on an external binary that may not be installed, guard isEnabled with a runtime check:

ts
isEnabled: () => {
  try {
    Bun.spawnSync(["rg", "--version"]);
    return true;
  } catch {
    return false;
  }
},

getAllTools() filters out disabled tools before sending the list to the model.

MIT Licensed.