Skip to main content
import { tools, read, write, edit, grep, bash, defineTool } from "smithers-orchestrator";
tools bundles all five tools keyed by name:
const { read, write, edit, grep, bash } = tools;

Sandboxing

All tools are sandboxed to rootDir (defaults to the workflow directory). Paths are resolved relative to this root; escapes via symlinks are rejected.
PolicyBehavior
Path resolutionRelative paths resolve against rootDir. Absolute paths must fall within root.
SymlinksRejected if target is outside sandbox.
Output sizeTruncated to maxOutputBytes (default 200KB).
Timeoutsbash and grep default to 60s; exceeded processes killed with SIGKILL.
Networkbash blocks network commands by default. See bash.

Tool Call Logging

Every invocation is logged to _smithers_tool_calls:
FieldDescription
runIdWorkflow run ID
nodeIdTask node that invoked the tool
iterationLoop iteration
attemptRetry attempt number
seqSequential call counter within the task
toolNameread, write, edit, grep, or bash
inputJsonSerialized input arguments
outputJsonSerialized output (truncated if over limit)
startedAtMsStart timestamp
finishedAtMsEnd timestamp
status"success" or "error"
errorJsonError details (if "error")

defineTool

Use defineTool() to wrap custom AI SDK tools with Smithers runtime context, deterministic idempotency keys, and durable tool-call logging.
import { defineTool } from "smithers-orchestrator";
import { z } from "zod";

const placeOrder = defineTool({
  name: "wholefoods.place_order",
  description: "Place a grocery order",
  schema: z.object({
    sku: z.string(),
  }),
  sideEffect: true,
  idempotent: false,
  async execute(args, ctx) {
    return await wholeFoods.placeOrder({
      sku: args.sku,
      idempotencyKey: ctx.idempotencyKey,
    });
  },
});
  • ctx.idempotencyKey is stable across retries and resumes for the same task iteration.
  • sideEffect: true opts the tool into Smithers side-effect tracking.
  • idempotent: false tells Smithers to warn resumed/retried agent loops when the tool was already called in a previous attempt.
  • Smithers logs start/finish records for every defineTool() call in _smithers_tool_calls.

Side Effects and Idempotency

Every custom tool that modifies external state must declare sideEffect: true. This is how Smithers knows to protect your workflow during retries and resumes. Without it, Smithers treats the tool as a pure read and will replay it freely — potentially sending duplicate emails, double-charging payments, or creating duplicate records. The two flags work together:
sideEffectidempotentSmithers behavior
false (default)true (default)Pure read. Safe to replay on retry. No warnings.
truetrueMutates external state, but calling it twice with the same input produces the same result (e.g. an upsert, a PUT request). Safe to replay. No warnings.
truefalseMutates external state and is not safe to replay (e.g. sending an email, placing an order, charging a payment). On retry, Smithers injects a warning telling the agent the tool was already called and it should verify external state before calling it again.
When sideEffect: true and idempotent: false, Smithers does two things on retry:
  1. Warns the agent. The retry prompt includes a message listing which non-idempotent tools were already called, so the agent can check external state before repeating them.
  2. Provides a stable idempotency key. ctx.idempotencyKey is deterministic for a given task + iteration, so you can pass it to external APIs that support idempotency (Stripe, AWS, etc.) to deduplicate on their end.
If your execute function has sideEffect: true, idempotent: false but does not accept the ctx parameter, Smithers logs a warning at startup. This is almost always a bug — you need ctx.idempotencyKey to safely handle retries.
// ✗ Bad: non-idempotent side effect without ctx
const sendEmail = defineTool({
  name: "email.send",
  schema: z.object({ to: z.string(), body: z.string() }),
  sideEffect: true,
  idempotent: false,
  async execute(args) {  // ← missing ctx parameter, Smithers warns
    await mailer.send(args);
  },
});

// ✓ Good: uses ctx.idempotencyKey to deduplicate
const sendEmail = defineTool({
  name: "email.send",
  schema: z.object({ to: z.string(), body: z.string() }),
  sideEffect: true,
  idempotent: false,
  async execute(args, ctx) {
    await mailer.send({ ...args, idempotencyKey: ctx.idempotencyKey });
  },
});

What counts as a side effect

A side effect is any mutation of state outside the sandbox. If the tool talks to an external API, writes to a database, sends a message, or triggers a webhook, it has a side effect. Mark it. File system changes inside the sandbox — writing files, editing code, running git commit — are not side effects in this sense. The built-in write, edit, and bash tools modify the working directory, but those changes are local, sandboxed, and tracked by git. They are inherently reversible (git checkout, git reset) and inspectable (git diff, git log). Smithers does not need retry warnings or idempotency keys for them.
ToolSide effect?Why
Built-in read, grepNoPure reads
Built-in write, editNoSandboxed file changes, tracked by git
Built-in bash (local commands)NoLocal execution within sandbox
Custom tool calling an external APIYesMutates state outside the sandbox
Custom tool writing to a databaseYesExternal persistent state
Custom tool sending a Slack messageYesIrreversible external communication
Custom tool creating a GitHub PRYesExternal state visible to others
The rule is simple: if you cannot undo it with git reset, mark it as a side effect.

read

Read a file from the sandbox.
{ path: string }  // relative to rootDir or absolute
Returns file contents as UTF-8. Throws "File too large" if size exceeds maxOutputBytes.
import { ToolLoopAgent as Agent } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { read, grep } from "smithers-orchestrator";

const codeAgent = new Agent({
  model: anthropic("claude-sonnet-4-20250514"),
  tools: { read, grep },
});
{/* outputs comes from createSmithers() */}
<Task id="review" output={outputs.review} agent={codeAgent}>
  Read the file src/auth.ts and identify any security vulnerabilities.
</Task>

write

Write content to a file. Creates parent directories as needed.
{
  path: string      // relative to rootDir or absolute
  content: string
}
Returns "ok". Throws "Content too large" if content exceeds maxOutputBytes. Logs content hash (SHA-256) and byte size; full content is not stored.
import { ToolLoopAgent as Agent } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { write, read } from "smithers-orchestrator";

const writerAgent = new Agent({
  model: anthropic("claude-sonnet-4-20250514"),
  tools: { write, read },
});

edit

Apply a unified diff patch to an existing file.
{
  path: string    // file to patch
  patch: string   // unified diff format
}
Returns "ok". The file must exist. Reads current contents, applies the patch via applyPatch, writes back. Throws on size limits ("Patch too large", "File too large") or mismatched context ("Failed to apply patch"). Logs patch hash and byte size.
--- a/src/auth.ts
+++ b/src/auth.ts
@@ -10,3 +10,4 @@
   const token = jwt.sign(payload, secret);
+  logger.info("Token issued", { userId: payload.sub });
   return token;

grep

Search for a regex pattern using ripgrep.
{
  pattern: string    // regex
  path?: string      // directory or file (default: rootDir)
}
Returns matching lines with file paths and line numbers (rg -n format). Exit code 1 (no matches) returns empty string. Exit code 2 throws stderr as error. Requires ripgrep in PATH.
src/auth.ts:15:  if (token.expired()) {
src/auth.ts:42:  validateToken(token);
tests/auth.test.ts:8:  const token = createTestToken();

bash

Execute a shell command.
{
  cmd: string                     // executable or command
  args?: string[]                 // arguments
  opts?: { cwd?: string }        // working directory (sandboxed)
}
Returns combined stdout and stderr. Working directory defaults to rootDir. Timeout: 60s (killed with SIGKILL via process group). Non-zero exit codes throw.

Network Blocking

Controlled by allowNetwork in RunOptions, --allow-network on CLI, or server config. Default: blocked. When blocked, the command string (executable + args) is checked against these fragments:
CategoryBlocked strings
HTTP clientscurl, wget
URL prefixeshttp://, https://
Package managersnpm, bun, pip
Git remote opsgit push, git pull, git fetch, git clone, git remote
Local git commands (git status, git diff, git log) are allowed.
import { ToolLoopAgent as Agent } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { bash } from "smithers-orchestrator";

const devAgent = new Agent({
  model: anthropic("claude-sonnet-4-20250514"),
  tools: { bash },
});
{/* outputs comes from createSmithers() */}
<Task id="lint" output={outputs.lint} agent={devAgent}>
  Run the linter on src/ and report any issues.
</Task>

Using Tools with Agents

Pass tools to an AI SDK agent, assign the agent to a <Task>:
import { ToolLoopAgent as Agent } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { read, write, edit, grep, bash } from "smithers-orchestrator";

const codeAgent = new Agent({
  model: anthropic("claude-sonnet-4-20250514"),
  tools: { read, write, edit, grep, bash },
  instructions: "You are a senior engineer. Use the available tools to complete tasks.",
});

const { Workflow, smithers, outputs } = createSmithers({
  result: z.object({ summary: z.string() }),
});

export default smithers((ctx) => (
  <Workflow name="refactor">
    <Task id="refactor" output={outputs.result} agent={codeAgent}>
      {`Refactor the function in ${ctx.input.file} to improve readability.`}
    </Task>
  </Workflow>
));
The full bundle works too:
import { ToolLoopAgent as Agent } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { tools } from "smithers-orchestrator";

const fullAgent = new Agent({
  model: anthropic("claude-sonnet-4-20250514"),
  tools,
});

Configuration

OptionDefaultDescription
rootDirWorkflow directorySandbox root
allowNetworkfalseAllow network commands in bash
maxOutputBytes200000 (200KB)Max output size per tool
toolTimeoutMs60000 (60s)Timeout for bash and grep
const result = await runWorkflow(workflow, {
  input: { file: "src/auth.ts" },
  rootDir: "/home/project",
  allowNetwork: false,
  maxOutputBytes: 500_000,
  toolTimeoutMs: 120_000,
});

See Also