Custom tools
The Tool interface in full — schema, execute contract, ToolResult, abort signals — with a complete worked example.
On this page
A tool is one object: a JSON-Schema definition the model sees, and an execute
function that does the work. This is the primary way to give an embedded agent
capabilities beyond the built-ins — a custom tool is the unit of extension.
The Tool interface
From packages/types/src/tools.ts:
interface ToolDefinition {
name: string;
description: string;
parameters: JsonSchema; // Record<string, unknown> — a JSON Schema (2020-12 subset)
}
interface ToolResult {
content: ToolResultContent[]; // TextContent | ImageContent
isError?: boolean;
/** Structured machine-readable payload stored on the ToolResultMessage. */
details?: Record<string, unknown>;
}
interface ToolExecutionContext {
cwd: string;
/** Aborts when the user interrupts the run. */
signal: AbortSignal;
/** Ask the permission engine (may prompt the user) before a sensitive action. */
requestPermission: PermissionRequester;
/** Report incremental progress; safe to call many times. */
onUpdate: (update: ToolUpdate) => void; // { text?: string; details?: Record<string, unknown> }
sessionId: string;
toolCallId: string;
}
interface Tool {
definition: ToolDefinition;
execute(input: Record<string, unknown>, ctx: ToolExecutionContext): Promise<ToolResult>;
}The contract in one sentence: execute must resolve with a ToolResult for every
expected outcome, including failure — isError: true is a normal return, not a
throw — and reject only for genuine programming errors (a bug, not "the deploy
failed"). The loop treats a rejection as a bug: it's caught, but the resulting
toolEnd reads as a runtime failure rather than a clean tool-level error the model can
reason about and retry from.
A complete worked example
import type { Tool } from "@arcturn/types";
const deployPreview: Tool = {
definition: {
name: "deploy_preview",
description: "Deploy the current branch to a preview environment and return its URL.",
parameters: {
type: "object",
properties: { env: { type: "string", enum: ["staging", "preview"] } },
required: ["env"],
},
},
async execute(input, ctx) {
const decision = await ctx.requestPermission({
toolName: "deploy_preview",
toolCallId: ctx.toolCallId,
subject: String(input.env),
description: `Deploy this branch to ${input.env}`,
});
if (decision.behavior === "deny") {
return { content: [{ type: "text", text: "Deploy declined." }], isError: true };
}
ctx.onUpdate({ text: "building…" });
const url = await runDeploy(String(input.env), { signal: ctx.signal });
return { content: [{ type: "text", text: `Deployed: ${url}` }], details: { url } };
},
};
const agent = createAgent({ ...base, tools: [...tools, deployPreview] });Walking through it:
parametersis the schema the model sees, validated against the model's actual call beforeexecuteever runs — a malformed call never reaches your code (seevalidateToolInputin@arcturn/core'sschema.ts).ctx.requestPermissionroutes through the samePermissionEngineevery built-in tool uses. A rule, a mode, or a prior "always allow" can pre-approve this without ever prompting — see Permissions from the SDK for exactly how the request resolves.ctx.onUpdatestreams progress astoolUpdateevents — call it as many times as you like; it's fire-and-forget.ctx.signalis the run'sAbortSignal. Pass it to anything cancelable (fetch,child_process, your own long-running call) soagent.abort()actually stops the work instead of merely stopping the agent from waiting on it.detailsrides on theToolResultMessagealongside the human-readablecontent— the place for a machine-readable payload (a URL, an id, a diff) that a downstream consumer of the session (a UI, a log processor) wants without re-parsing text.
Mixing tool sources
The model sees one flat toolbox regardless of where tools came from — mix custom
tools freely with createDefaultTools() and MCP-bridged tools:
import { createDefaultTools } from "@arcturn/tools";
import type { Tool } from "@arcturn/types";
declare const myCustomTools: Tool[]; // e.g. [deployPreview], plus mcpManager.tools()
const { tools: builtins } = createDefaultTools({ cwd });
const allTools = [...builtins, ...myCustomTools];Swap the list at runtime with agent.setTools(newTools) — useful for adding an MCP
server's tools once it connects, or removing a tool mid-session.
Error handling
Two different things can go wrong in a tool call, and they're handled differently:
- An expected failure (bad input the schema didn't catch, a downstream API
returning 4xx, a file not found) → return
{ content: [...], isError: true }. The model sees this as a normal tool result and can retry, ask a follow-up, or give up gracefully. - A programming error (a null-pointer bug in your
execute, an unhandled promise rejection) → let it throw or reject. The loop catches it, but the resultingtoolEndand the eventualrunEnd(if the whole run can't continue) surface it as a failure rather than model-visible feedback — which is the right signal, because it's a bug in your tool, not something the model did wrong.
import { errorText } from "@arcturn/core";
import type { ToolExecutionContext, ToolResult } from "@arcturn/types";
async function execute(
input: Record<string, unknown>,
ctx: ToolExecutionContext,
): Promise<ToolResult> {
const path = typeof input.path === "string" ? input.path : undefined;
if (!path) {
// Expected — the model sent something the schema alone couldn't reject.
return { content: [{ type: "text", text: "path must be a string" }], isError: true };
}
try {
const contents = await readSomething(path, ctx.signal);
return { content: [{ type: "text", text: contents }] };
} catch (error) {
// Also expected: the read failed for an external reason. Report it, don't throw.
return {
content: [{ type: "text", text: `Could not read ${path}: ${errorText(error)}` }],
isError: true,
};
}
}Hooks vs. permissions
Both AgentHooks and the permission engine can intervene around a tool call, but they
answer different questions:
- Permissions answer "is this specific action allowed?" — per-call, addressed to
the user (or a rule standing in for one), and the vocabulary is allow/deny/ask. Only
a tool that calls
ctx.requestPermissionis gated at all; nothing gates a tool from the outside. - Hooks (
AgentOptions.hooks) wrap every tool call unconditionally —beforeToolCallcan block a call outright before it starts (for cases a permission rule can't express, like a regex over the raw command), andafterToolCallcan observe or rewrite every result for audit logging. See Advanced: hooks for the full shape and an example.
The two compose: a hook can block a call before permissions are ever consulted, and a
tool's own requestPermission call still runs through the same engine either way.
Bindable tools
createSubagentTool, createTodoTool, and createPlanTool (all from @arcturn/core)
implement an extra, optional BindableTool interface — a bindAgent(controller)
method the agent calls automatically when they appear in tools. You won't usually
implement this yourself; it exists so state tools can reach back into the owning
agent (to set todos, request plan approval, switch permission mode) without every
custom tool author needing that power. See Advanced: sub-agents
for createSubagentTool specifically.