v0.1.0GitHubnpm
Concepts

Tools

Tools are typed functions the agent can call. Every tool declares a scope (read, write, or exec) and a parallelSafe flag — the permission engine consults the first, the scheduler consults the second. The built-in set covers files, shell, search, and a session-scoped task list; anything else is one Tool implementation away.

#Default tools

defaultTools() returns a fresh ToolRegistry preloaded with the ten built-ins, in canonical order. The table below lists each tool’s scope, whether it’s parallel-safe, the required and notable optional inputs, and what comes back in ToolResult.content.

ToolscopeparallelSafeInputReturns
Readreadtruefile_path, offset?, limit?cat -n-formatted text (default 2000 lines, lines capped at 2000 chars), or an image content block for PNG/JPEG/GIF/WebP under 5 MiB. Marks the path on the file-read tracker.
Writewritefalsefile_path, contentwrote N bytes to …. Atomic (temp + fsync + rename). Refuses to overwrite a file that has not been Read in this session.
Editwritefalsefile_path, old_string, new_string, replace_all?Edited path: +N lines. Requires Read-before-Edit. Preserves the file’s dominant line ending. Errors if old_string is not unique and replace_all is false.
Bashexecfalsecommand, timeout_ms?, description?Combined stdout/stderr (separated by ---) followed by exit: N. Default timeout 120 000 ms, clamped to [100, 1 800 000]. Per-stream cap 30 000 chars, combined cap 30 000 chars.
Globreadtruepattern, path?Newline-separated relative paths, sorted by mtime descending, capped at 1000. Dotfiles and .git/.hg/.svn are excluded.
Grepreadtruepattern, path?, glob?, type?, output_mode?, -i/-n/-A/-B/-C, multiline?, head_limit?files_with_matches (default), content, or count. Output line-capped by head_limit (default 250) and char-capped at 30 000.
TaskCreatewritetruesubject, description, active_form?, metadata?Task #id created: subject. Persists through ctx.sessionStore.createTask.
TaskListreadtrue(none)One line per task: #id [status] subject, with active blockers in brackets.
TaskGetreadtruetask_idMulti-line task dump from the session store.
TaskUpdatewritetruetask_id plus any patchable field: status, subject, owner, add_blocks/add_blocked_by, remove_blocks/remove_blocked_by, metadata.Task #id updated: status pending → in_progress; …. Setting status: "deleted" removes the task.

Two related tools register themselves on first use rather than living in defaultTools(): Skill (registered when a skills directory loads, see Skills) and the per-subagent tool created from opts.subagents. Both have scope: "exec" and parallelSafe: false.

#The file-read tracker

Read, Write and Edit share a per-session FileReadTracker held in memory on the session. Read marks a path as read. Write and Edit both consult the tracker before they touch the file system — the rule is not Edit-only.

  • Edit refuses to run if the target path has not been Read in the current session and returns an is_error: true result with the message “You must Read this file before editing it.”

  • Write applies the same rule, but only when the target already exists. Creating a brand-new file does not require a prior Read. Overwriting one does.

  • A successful Write also marks the path as read, so a follow-up Edit on the same file in the same session does not need an explicit Read.

  • The tracker is session-scoped, in-memory, and never persisted. A new session, a session reset, or an SDK restart all clear it.

Footgun. An out-of-band file change (a separate process, the user editing the file, another agent) does not invalidate the tracker. The agent will still believe its earlier Read snapshot is current and may Edit on top of stale assumptions. If the file might have moved underneath you, Read it again before editing.

#Bash details

  • The command runs under $SHELL -c on POSIX (or %ComSpec% /c on Windows) with cwd set to ctx.cwd and the current process’s env.
  • On POSIX the child is started detached so the SDK can kill the entire process group with SIGTERM (then SIGKILL after 2 s) on timeout or abort.
  • A non-zero exit code is not a tool error. The output carries exit: N and the model decides how to interpret it. is_error: true is only set for timeouts, aborts, and spawn failures.
  • Each stream is truncated independently to 30 000 chars; the combined string is then capped again at 30 000 chars. A trailing … (N chars truncated) marker is appended whenever a cap fires.

#Glob and Grep details

  • Both tools prefer rg on PATH and fall back when it is absent — Glob falls back to fast-glob, Grep to a pure-JS scanner.
  • Ripgrep’s default .gitignore handling is in effect; the fast-glob fallback ignores VCS directories (.git, .hg, .svn) but does not read .gitignore. Match counts and result sets can differ across the two implementations when .gitignore entries exist.
  • Glob results are mtime-sorted and capped at 1000. Grep output is line-capped by head_limit (default 250) and then the combined string is char-capped at 30 000.

#Task tools and the session store

All four task tools read and write the session-scoped task list through ctx.sessionStore. With the default InMemorySessionStore the list survives the lifetime of the SDK process; with a persistent store (e.g. a Drizzle/SQLite adapter) it survives across runs and processes. The task tools are the canonical reason a tool needs sessionId and sessionStore on its context.

#Scope and the permission engine

Every tool’s scope field is what the permission engine’s mode default reads to decide allow vs. ask vs. deny. Read-scope tools and the task tools are always allow-by-default regardless of mode; write scope is allowed under acceptEdits/yolo; exec is allowed only under yolo. See Permissions for the full resolution order.

#Registry

ToolRegistry is a thin Map<string, Tool> with a stable iteration order. The Agent takes the registry, calls list() for the JSON schemas it sends to the model, and get(name) per tool call during scheduling.

MethodBehavior
register(tool)Adds a tool. Throws ConfigError if the name is already taken — the registry is strict, there is no implicit overwrite.
get(name)Returns the tool or undefined.
list()Returns the registered tools in insertion order.
schemas()Returns ToolSchema[] — just name, description, input_schema — for handing to providers.

There is no unregister in v1. To build a reduced tool set, construct a fresh ToolRegistry and register only the tools you want — see the read-only recipe below.

The Agent accepts a registry through opts.tools; if you omit it the agent calls defaultTools() for you. MCP tools, skills, and per-agent subagents are registered onto the registry the Agent ends up holding, so anything in there at construction time is also visible to those subsystems.

#Custom tools

A tool is anything that satisfies the Tool interface exported from @skawld/agent-sdk/tools. Eight fields, nothing else — three properties for the model-facing schema, two for the scheduler and permission engine, three methods for the runtime.

tool-interface.ts
import type { Tool, ToolContext, ToolResult, ToolScope } from "@skawld/agent-sdk/tools";

export interface Tool<Input = Record<string, unknown>> {
  readonly name: string;
  readonly description: string;
  readonly input_schema: {
    type: "object";
    properties: Record<string, unknown>;
    required?: string[];
  };
  readonly scope: ToolScope;        // "read" | "write" | "exec"
  readonly parallelSafe: boolean;

  validate(raw: Record<string, unknown>): Input;
  execute(input: Input, ctx: ToolContext): Promise<ToolResult>;
  summarize(input: Input): string;  // required, not optional
}

#Fields

FieldTypeNotes
namestringThe identifier the model sees. Must be unique within the registry; registering a duplicate throws ConfigError. MCP tools use the reserved mcp__<server>__ prefix — avoid that prefix for your own tools.
descriptionstringShown to the model in the tool list. Document edge cases, units, and footguns here — the model can’t see your source.
input_schema{ type: "object"; properties; required? }Plain JSON Schema object. Providers translate this for both the Anthropic and OpenAI tool-use formats. Use required to surface mandatory params at the protocol level even though validate is the actual gate.
scope"read" | "write" | "exec"Consulted by the permission engine’s mode default. Pick the one that matches the worst-case side effect — a network write is write; running arbitrary user code or shell is exec.
parallelSafebooleanWhether the scheduler may run this tool concurrently with adjacent parallel-safe calls. See Parallel-safe tools. All read-scope built-ins set this to true; Bash, Write, and Edit set it to false.
validate(raw)(raw: Record<string, unknown>) => InputNarrow raw model input into your typed Input. Throw a ToolExecutionError (or any Error) on a bad shape — the scheduler turns it into a structured tool-result block the model can recover from. Called before the permission engine — so a malformed call never prompts the user.
execute(input, ctx)(Input, ToolContext) => Promise<ToolResult>Do the work, return a ToolResult. Respect ctx.signal and surface failures as is_error: true rather than throwing, except for AbortError which the scheduler unwinds.
summarize(input)(Input) => stringOne-line description used in permission_request events, tool-call log lines, and any UI that lists pending calls. Called after validate succeeds, before execute and before permission resolution.

#ToolContext

execute receives a ToolContext built by the scheduler for each call. The shape is fixed — these are all the fields:

tool-context.ts
interface ToolContext {
  cwd: string;                            // working directory for relative paths
  signal: AbortSignal;                    // honor this — abort terminates the call
  fileReadTracker: FileReadTracker;       // shared Read/Write/Edit tracker
  sessionId: string;                      // current session id
  runId: string;                          // current run id, for logging
  sessionStore: SessionStore;             // persistent task/session store
  emit?: (event: Event) => void;          // exec-scope only; stream live events
}
  • cwd. Resolve relative file_path-style inputs against this — the helpers in src/tools/_helpers.ts ( resolvePath) do exactly that and also expand a leading ~/.

  • signal. When the run aborts, this fires. execute should stop work, kill children, and return (or throw AbortError). A spinning loop that ignores the signal will block the agent from shutting down.

  • fileReadTracker. Available to every tool, but only Read/Write/Edit consult it today. Custom file-touching tools should either reuse it (call markRead after a successful read, check hasRead before destructive writes) or stay out of its way.

  • sessionId / sessionStore. The persistence handle for session-scoped state. Use them the same way the task tools do: ctx.sessionStore.createTask(ctx.sessionId, …).

  • emit. Only wired for exec-scoped tools. Calls from read-scoped tools are silent no-ops. Calls made after execute resolves are dropped. Use it to stream incremental progress between this call’s tool_call_start and tool_call_end brackets.

#ToolResult

Three fields. content is what the model sees in the tool_result block; summary is human-facing; is_error tells the model the call failed.

tool-result.ts
interface ToolResult {
  content:
    | string
    | Array<
        | { type: "text"; text: string }
        | {
            type: "image";
            source:
              | { type: "base64"; media_type: string; data: string }
              | { type: "url"; url: string };
          }
      >;
  summary: string;
  is_error?: boolean;
}
  • content as a string is the common case. Use the array form when you need to interleave text with one or more image blocks (the Read tool does this for images).

  • summary is required and must be a string. The scheduler uses it for the tool_call_end log payload and any host-side UI. Keep it short.

  • is_error: true turns the tool_result into a recoverable model-visible error. Prefer this to throwing — a thrown error becomes Tool failed: <message> with is_error: true set anyway, but you lose control over the message and summary.

#Worked example: an HTTP-backed tool

A realistic custom tool: validate input shape, forward ctx.signal to fetch, map non-2xx responses to is_error rather than throwing, and produce a useful summary.

lookup-customer.ts
import type { Tool, ToolContext, ToolResult } from "@skawld/agent-sdk/tools";

interface LookupInput {
  email: string;
  include_orders?: boolean;
}

export class LookupCustomerTool implements Tool<LookupInput> {
  readonly name = "lookup_customer";
  readonly description =
    "Look up a customer in the CRM by email. Returns id, plan, and optionally recent orders.";
  readonly input_schema = {
    type: "object" as const,
    properties: {
      email: { type: "string", description: "Customer email address." },
      include_orders: { type: "boolean", description: "Include the last 5 orders." },
    },
    required: ["email"],
  };
  readonly scope = "read" as const;       // pure read against an external system
  readonly parallelSafe = true;           // safe to run alongside other reads

  constructor(private readonly opts: { baseUrl: string; apiKey: string }) {}

  validate(raw: Record<string, unknown>): LookupInput {
    if (typeof raw.email !== "string" || !raw.email.includes("@")) {
      throw new Error("email must be a string containing '@'");
    }
    return {
      email: raw.email,
      include_orders: raw.include_orders === true,
    };
  }

  summarize(input: LookupInput): string {
    return input.include_orders
      ? `Look up ${input.email} (with orders)`
      : `Look up ${input.email}`;
  }

  async execute(input: LookupInput, ctx: ToolContext): Promise<ToolResult> {
    const url = new URL("/customers/lookup", this.opts.baseUrl);
    url.searchParams.set("email", input.email);
    if (input.include_orders) url.searchParams.set("orders", "5");

    let res: Response;
    try {
      res = await fetch(url, {
        headers: { authorization: `Bearer ${this.opts.apiKey}` },
        signal: ctx.signal,                 // honor abort
      });
    } catch (err) {
      // Network errors and aborts land here.
      const msg = err instanceof Error ? err.message : String(err);
      return {
        content: `CRM request failed: ${msg}`,
        summary: this.summarize(input),
        is_error: true,
      };
    }

    if (res.status === 404) {
      return {
        content: `No customer found for ${input.email}.`,
        summary: this.summarize(input),
        // Not is_error — "not found" is a valid result the model can act on.
      };
    }

    if (!res.ok) {
      return {
        content: `CRM returned ${res.status} ${res.statusText}`,
        summary: this.summarize(input),
        is_error: true,
      };
    }

    const body = await res.json();
    return {
      content: JSON.stringify(body),
      summary: this.summarize(input),
    };
  }
}

#Worked example: shorthand object form

The class form above is preferred for stateful tools and matches the built-ins. For pure-function tools a plain object that satisfies the interface works just as well.

tools.ts
import { defaultTools } from "@skawld/agent-sdk/tools";
import type { Tool, ToolContext, ToolResult } from "@skawld/agent-sdk/tools";

interface LookupInput { email: string }

const lookupCustomer: Tool<LookupInput> = {
  name: "lookup_customer",
  description: "Find a customer by email in the CRM.",
  scope: "read",
  parallelSafe: true,
  input_schema: {
    type: "object",
    properties: { email: { type: "string" } },
    required: ["email"],
  },
  validate(raw) {
    if (typeof raw.email !== "string") throw new Error("email must be a string");
    return { email: raw.email };
  },
  summarize({ email }) {
    return `Look up ${email} in CRM`;
  },
  async execute({ email }, ctx: ToolContext): Promise<ToolResult> {
    const customer = await crm.find({ email, signal: ctx.signal });
    return {
      content: JSON.stringify({ id: customer.id, plan: customer.plan }),
      summary: `found customer ${customer.id}`,
    };
  },
};

const tools = defaultTools();
tools.register(lookupCustomer);

#Parallel-safe tools

The scheduler walks the model’s tool-call blocks in arrival order and partitions them into adjacent batches. Calls whose tools declare parallelSafe: true are grouped together; any call where parallelSafe: false, or where the tool is unknown, or where input validation failed, becomes a singleton serial batch. The original call order is preserved across batch boundaries.

  • Parallel batches dispatch all their calls concurrently via mergeAsyncGenerators, capped at the agent’s toolConcurrency (default 10, override with SKAWLD_MAX_TOOL_CONCURRENCY at construction time). Their events interleave in completion order — consumers demultiplex by tool_use_id.

  • Serial batches run one call at a time and await completion before the next batch starts. The default tool set is deliberately conservative: Read, Glob, Grep, and all four task tools are parallel-safe; Write, Edit, and Bash are not.

  • parallelSafe only controls concurrency. It does not grant any extra permission, nor does it relax ctx.signal semantics.

  • Use parallelSafe: true when concurrent execution would be a no-op or commutative — multiple HTTP GETs, two Reads of different files, parallel database lookups. Keep it false for any tool that mutates shared state (the filesystem, a record store, an external system) where ordering matters.

#Recipes

#Read-only Read + Grep tool set

Skip defaultTools() entirely and construct a fresh registry. The agent only sees the tools you register — everything else is unknown to the model and cannot be called.

read-only-tools.ts
import { Agent } from "@skawld/agent-sdk";
import {
  ToolRegistry,
  ReadTool,
  GlobTool,
  GrepTool,
} from "@skawld/agent-sdk/tools";

const tools = new ToolRegistry();
tools.register(new ReadTool());
tools.register(new GlobTool());
tools.register(new GrepTool());

const agent = new Agent({
  provider,
  model: "claude-opus-4-5",
  tools,
  // No write/exec tools registered, so the model literally cannot call them.
});

Compare this with the read-only-agent recipe in Permissions: that one keeps the full tool set and denies writes at the permission layer; this one removes write tools at the registry layer. The permission-layer version is the easier safety net once you already trust the defaults; the registry-layer version is the right one when you want the model to never even consider writes.

#Adding a custom tool to the defaults

custom-tool.ts
import { Agent } from "@skawld/agent-sdk";
import { defaultTools } from "@skawld/agent-sdk/tools";
import { LookupCustomerTool } from "./lookup-customer";

const tools = defaultTools();
tools.register(
  new LookupCustomerTool({
    baseUrl: process.env.CRM_URL!,
    apiKey: process.env.CRM_KEY!,
  }),
);

const agent = new Agent({ provider, model: "claude-opus-4-5", tools });

Need pluggable tools written in another language, or want to wire in an off-the-shelf tool server? Mount them as MCP servers — see MCP. MCP tools register onto the same ToolRegistry at Agent startup and are subject to the same permission rules, but they always run as singleton serial batches.