Skip to content
Warlock.js v4

Define tools

Tools are async functions the model can call by name during a trip loop. You declare one with ai.tool({...}), pass it in agent({ tools: [...] }), and the agent handles dispatch, validation, and error surfacing for you.

import { ai } from "@warlock.js/ai";
import { v } from "@warlock.js/seal";
const searchTool = ai.tool({
name: "search_catalog",
description: "Search the product catalog. Returns matching products with SKU, name, price.",
version: "v2",
action: ({ query }) => `Searching the catalog for "${query}"`,
mode: "feedback", // default — or "silent"
input: v.object({ query: v.string(), limit: v.number().optional() }),
execute: async ({ query, limit }, ctx) => {
return await searchProducts(query, limit ?? 10);
},
});

Returns a ToolContract<TInput, TOutput>. One tool can be attached to many agents.

Two fields, two audiences:

  • description — what the LLM reads when deciding whether to call this tool. Be specific about what it returns.
  • action — a present-progressive UI string surfaced to humans on agent.tool.calling / agent.tool.called events. Optional.
ai.tool({
name: "search_catalog",
description: "Search the product catalog. Returns matching products with SKU, name, price.",
action: ({ query }) => `Searching the catalog for "${query}"`,
// ...
});

action accepts a string or a function. The function runs AFTER input validation; throws inside it are swallowed — a UI string isn’t worth aborting LLM dispatch over.

Input is typed as StandardSchemaV1<T>. Recommended: @warlock.js/seal. Zod, Valibot, and hand-rolled schemas all interop because they all implement the same standard.

const tool = ai.tool({
name: "get_weather",
description: "Return the current weather for a city.",
input: v.object({
city: v.string(),
units: v.enum(["c", "f"]).optional(),
}),
execute: async ({ city, units }) => fetchWeather(city, units ?? "c"),
});

The agent calls input["~standard"].validate(rawArgs) before invoking execute. Validation failures do not throw: the error is recorded on the ToolCall, fed back to the model as a tool-error message, and the model gets a chance to correct itself within maxTrips.

Whatever execute resolves with is JSON.stringify’d and sent back as the next trip’s tool message. Strings pass through unchanged. Throw (or return a rejected promise) to signal failure — the agent records the error and tells the model what went wrong.

execute: async ({ query }) => {
if (!query) throw new Error("query is required");
return { items: await search(query) };
}

Default is "feedback".

  • mode: "feedback" — standard round-trip. The tool’s result feeds back into the next trip; the model reads it and replies. Use for tools whose output the model needs to narrate: search_catalog, search_kb, ask_question.
  • mode: "silent" — fire-and-forget. The result is NOT fed back to the model. When EVERY tool call in one generation is silent, the agent loop terminates after dispatch. Use for pure side-effect tools: update_state, set_locale, telemetry pings.
ai.tool({
name: "update_state",
description: "Persist customer slot-fill across turns.",
mode: "silent",
input: v.object({ preferences: v.array(v.string()).optional() }),
execute: async (patch, ctx) => {
ctx.artifacts.stateUpdate = patch;
return { ok: true }; // model never sees this
},
});

The all-silent rule is load-bearing: silent + feedback in the same generation → loop continues (the feedback tool still round-trips, the silent piggybacks).

See Silent tools recipe for the full pattern and provider behavior table.

execute accepts an optional second argument — a ToolContext with a mutable artifacts bag plus the dispatch’s signal. Use artifacts to capture system-only data (renderable blocks, citations, files, telemetry) the LLM should never see.

ai.tool({
name: "search_catalog",
input: v.object({ query: v.string() }),
execute: async (input, ctx) => {
const items = await searchItems(input.query);
ctx.artifacts.blocks ??= [];
ctx.artifacts.blocks.push({ type: "items", itemIds: items.map(i => i.id) });
return { total: items.length }; // LLM-visible — what the agent reasons over
},
});

Under a supervisor: the bag starts empty per iteration, accumulates across tool calls in that iteration, and merges into state when the iteration ends (auto-spread by default; configurable via finalizeArtifacts). See Run supervisor.

Standalone (no supervisor): the framework supplies { artifacts: {} }. Mutations are harmless no-ops — useful if the same tool gets used in both contexts.

A supervisor declares artifactsSchema and tools registered to it inherit typed ctx.artifacts.*:

ai.supervisor({
artifactsSchema: v.object({
blocks: v.array(blockSchema).optional(),
citations: v.array(citationSchema).optional(),
}),
// tools see ctx.artifacts typed as { blocks?, citations? }
});

Standalone tools fall back to Record<string, unknown>.

Every tool-call failure surfaces as ToolExecutionError (code: "TOOL_EXEC_FAILED") with toolName and tripIndex. The root cause attaches via error.cause:

  • Schema validation → SchemaValidationError wrapped in ToolExecutionError
  • Your execute threw → the thrown error is cause
  • Provider failure mid-dispatch → a ProviderError subclass

See Handle errors.

const result = await myAgent.execute("Pick a city and tell me the weather.");
for (const call of result.report.toolCalls) {
console.log(call.tripIndex, call.name, call.input, call.output, call.duration);
}

Each ToolCall carries startedAt, endedAt, duration.

  • agent.tool.calling{ tool, input, tripIndex }
  • agent.tool.calledToolCall & { tool } (full record)
  • agent.tool.failed{ tool, input, error, tripIndex }

Subscribe at factory / instance / per-call.

Higher primitives expose .asTool():

const wrapped = myWorkflow.asTool({
description: "Run the catalog ingestion workflow",
inputSchema: v.object({ url: v.string() }),
});
const agent = ai.agent({ model, tools: [wrapped] });

Workflow errors surface as ToolExecutionError with cause pointing at the original WorkflowError subclass.