Defining Tools
The Tool / ToolSet shape — parameters, execute, return values, errors, and Zod argument inference.
A tool is a function the model can call by name. You pass a ToolSet (a plain Record<string, Tool>) to generateText or streamChat; the presence of tools switches on the agentic loop. Each tool declares a parameters schema the model must satisfy and an optional execute you run server-side. Tools are defined inline — there is no tool() wrapper to import; you write the object literally.
The Tool shape
interface Tool<Args = unknown, Result = unknown> {
type?: 'function' | 'provider'; // default 'function'; 'provider' = provider-executed
description?: string;
parameters: StandardSchemaV1<unknown, Args> | JSONSchema;
execute?: (args: Args, ctx: ToolExecuteContext) => Promise<Result> | Result;
needsApproval?:
| boolean
| ((args: Args, ctx: ToolExecuteContext) => boolean | Promise<boolean>);
outputSchema?: JSONSchema; // carried metadata only (populated by MCP tools)
providerTool?: Record<string, unknown>; // raw native definition for a 'provider' tool
}
type ToolSet = Record<string, Tool>;There are four kinds of tool, distinguished by these fields:
| Tool kind | How it runs | Shape | Example |
|---|---|---|---|
| Server function tool | The SDK runs your execute | execute set, type omitted | getWeather |
| Client tool | No execute — the loop breaks and the UI does a round-trip | execute omitted | confirmPurchase (see Client tools) |
| Provider tool | The model provider runs it during the turn | type: 'provider' + providerTool | anthropicWebSearch(), openaiWebSearch(), googleSearch() (see Provider-executed tools) |
| Sub-agent tool | A nested agentic loop runs | built by agentTool(...) | agentTool({ name: 'researcher', … }) (see Sub-agents) |
You rarely set type/providerTool by hand — the anthropicWebSearch() / openaiWebSearch() / googleSearch() factories and agentTool(...) build those shapes for you.
| Field | Type | Required | Notes |
|---|---|---|---|
description | string | no | What the tool does. The model uses this to decide when to call it — write it carefully. |
parameters | StandardSchemaV1 or JSONSchema | yes | The argument schema. A Standard Schema (Zod, Valibot, …) or a raw JSON Schema. |
execute | (args, ctx) => Result | Promise<Result> | no | Runs the tool server-side. Omit it for a client tool (see below). |
needsApproval | boolean or predicate | no | Gates the call on approval. true always asks; a predicate gets the parsed args + ctx and is awaited (a throwing predicate counts as approval-required — safe side). See below. |
outputSchema | JSONSchema | no | Expected result shape — metadata only, never sent on chat wires and not validated by the loop. MCP tools populate it from the server's outputSchema. |
The map key is the tool name the model calls — there is no name field on Tool itself.
Tool approval (needsApproval)
Two modes, chosen by whether you pass approveToolCall:
Server mode — decide inline, the loop never pauses. Denials become an
is_error tool_result ('Tool call denied.') the model can react to; they do
NOT count toward the runaway error guard. A throwing approveToolCall denies.
const { text } = await generateText({
model: anthropic('claude-opus-4-8'),
messages,
maxSteps: 5,
tools: {
deleteFile: {
description: 'Delete a file by path.',
parameters: deleteSchema,
needsApproval: (args) => (args as { path: string }).path.startsWith('/prod'),
execute: deleteFile,
},
},
approveToolCall: async (call, { messages }) => {
// call = { toolCallId, toolName, args } — decide however you like.
return await policyAllows(call);
},
});Client mode — omit approveToolCall and the loop breaks instead, handing
the decision to your UI. See the approval round-trip.
const tools = {
getWeather: { description: 'Get weather', parameters: /* … */, execute: /* … */ },
search: { description: 'Search the web', parameters: /* … */, execute: /* … */ },
};parameters: raw JSON Schema or Standard Schema
parameters accepts either form. The SDK resolves whichever you give it to JSON Schema once (via schema/bridge.ts) before sending it on the wire, and — for Standard Schemas — validates the model's arguments against it before calling execute.
Raw JSON Schema (zero dependencies)
The first-class, zero-dependency path. Type args yourself.
import { generateText } from '@deuz-sdk/core';
import { createAnthropic } from '@deuz-sdk/core/anthropic';
const anthropic = createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });
const { text } = await generateText({
model: anthropic('claude-opus-4-8'),
messages: [{ role: 'user', content: 'What is the weather in Paris?' }],
maxSteps: 5,
tools: {
getWeather: {
description: 'Get the current weather for a city.',
parameters: {
type: 'object',
properties: { city: { type: 'string' } },
required: ['city'],
additionalProperties: false,
},
execute: async (args: { city: string }) => ({ city: args.city, tempC: 22, sky: 'sunny' }),
},
},
});
console.log(text);With a raw JSON Schema there is no zero-dep validator, so the SDK accepts the model's parsed arguments as-is and passes them straight to execute — annotate args yourself for type safety.
Standard Schema (Zod) with inferred args
Pass any Standard Schema instance (Zod, Valibot, ArkType, …). The schema's output type flows into execute, so args is fully typed with no annotation:
import { generateText } from '@deuz-sdk/core';
import { createAnthropic } from '@deuz-sdk/core/anthropic';
import { z } from 'zod';
const anthropic = createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });
const { text } = await generateText({
model: anthropic('claude-opus-4-8'),
messages: [{ role: 'user', content: 'Weather in Paris in Celsius?' }],
maxSteps: 5,
tools: {
getWeather: {
description: 'Get the current weather for a city.',
parameters: z.object({
city: z.string().describe('City name, e.g. "Paris"'),
unit: z.enum(['c', 'f']).default('c'),
}),
// `args` is inferred as { city: string; unit: 'c' | 'f' } — no annotation.
execute: async (args) => ({ city: args.city, temp: 22, unit: args.unit }),
},
},
});With a Standard Schema the SDK validates the model's arguments before execute runs; on a validation failure it does not call execute — it feeds an is_error tool result back to the model so it can retry with corrected arguments.
Optional peer. Converting a Standard Schema to JSON Schema needs the optional peer
@standard-community/standard-json. If it isn't installed, the call throws a clear, actionableInvalidRequestError. Install it, or pass a raw JSON Schema instead — core stays zero-runtime-dependency either way.
The execute context
execute receives the validated args first, then a ToolExecuteContext:
interface ToolExecuteContext {
toolCallId: string;
/** Conversation-so-far (immutable snapshot) for context-aware tools. */
messages: Message[];
/** Propagated from the call — long-running tools should honor it. */
signal?: AbortSignal;
}| Field | Type | Notes |
|---|---|---|
toolCallId | string | The provider-assigned id for this specific call. Useful for logging/tracing. |
messages | Message[] | Immutable snapshot of the conversation so far — read it for context, do not mutate it. |
signal | AbortSignal (optional) | The call's signal, propagated through. Honor it in long-running tools. |
Honoring the abort signal
The signal from the originating generateText / streamChat call is forwarded to every execute. Forward it into your own fetch (or any abortable I/O) so a cancelled request tears the tool down too:
import { generateText } from '@deuz-sdk/core';
import { createAnthropic } from '@deuz-sdk/core/anthropic';
import { z } from 'zod';
const anthropic = createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });
const controller = new AbortController();
const result = await generateText({
model: anthropic('claude-opus-4-8'),
messages: [{ role: 'user', content: 'Search for recent SDK news.' }],
maxSteps: 5,
signal: controller.signal,
tools: {
search: {
description: 'Search the web.',
parameters: z.object({ query: z.string() }),
execute: async ({ query }, { signal }) => {
// Forward the signal so an upstream abort cancels this fetch too.
const res = await fetch(`https://example.com/search?q=${encodeURIComponent(query)}`, {
signal,
});
return res.json();
},
},
},
});Return values
execute may return anything JSON-serializable (or a Promise of it). The return value becomes the result on a tool_result part and is sent back to the model verbatim, so prefer compact, structured objects over prose.
execute: async ({ city }) => ({ city, tempC: 22, sky: 'sunny' }), // becomes the tool_resultThe result is wrapped into a canonical tool_result part keyed by the call's id, which the next model turn reads:
interface ToolResultPart {
type: 'tool_result';
toolUseId: string;
result: unknown;
isError?: boolean;
}Thrown errors self-heal
A tool that throws is never surfaced as a rejected promise. The loop catches the throw, wraps it as an is_error tool_result, and feeds it back to the model so it can recover or apologize. Two cases produce an error result automatically:
executethrows — the loop catches it (viaToolExecutionError) and sendsisError: truewith the error message as the result.- A Standard Schema rejects the model's arguments —
executeis skipped and anInvalid arguments: …error result is sent instead.
tools: {
getWeather: {
parameters: z.object({ city: z.string() }),
execute: async ({ city }) => {
if (city === 'Atlantis') throw new Error('Unknown city');
// the throw is caught and returned to the model as an is_error tool_result
return { city, tempC: 22 };
},
},
}To guard against a model looping on a broken tool, the same tool failing on three consecutive steps hard-stops the loop. Every tool_use always gets exactly one tool_result (a hard provider requirement). See the tool loop for the full self-healing and runaway-guard rules.
Client tools (no execute)
Omit execute and the tool becomes a client tool: the model can request it, but the loop has no way to run it server-side. When a step calls a client tool, the loop stops early and returns the call in result.toolCalls — your application owns the round-trip and appends the matching tool_result itself.
tools: {
// No `execute`: the model can request it, but you fulfill it client-side.
openMap: {
description: 'Open the map UI centered on a location.',
parameters: z.object({ lat: z.number(), lng: z.number() }),
},
}prepareStep and activeTools
Two options on CommonCallOptions give you per-step control over the loop — both work identically in generateText and streamChat, and both only matter when tools is present.
activeTools?: string[] is a static filter: only these tools keys are sent to the model, on every step. An unknown name logs a warning and is ignored; if the whole list ends up matching nothing, it fails open (the full tool set is sent) rather than silently starving the model of tools.
const result = await generateText({
model: anthropic('claude-opus-4-8'),
messages,
maxSteps: 5,
tools: { getWeather, search, deleteFile },
activeTools: ['getWeather', 'search'], // deleteFile is defined but never offered
});prepareStep is a per-step hook: it runs before every model call the loop makes, after automatic compaction has run — so it sees the already-compacted history and gets the final word on it.
prepareStep?: (ctx: {
stepIndex: number;
messages: Message[];
/** Cumulative REAL usage across all prior steps, sub-agents included. */
usage: Usage;
}) => PrepareStepResult | undefined | Promise<PrepareStepResult | undefined>;
interface PrepareStepResult {
messages?: Message[]; // becomes the base history for THIS and all FOLLOWING steps
activeTools?: string[]; // THIS step only — overrides the static activeTools, not intersects it
toolChoice?: ToolChoice; // THIS step only
model?: LanguageModel; // THIS step only (return it every step to persist a swap)
}Return undefined (or omit fields) to keep the current settings. Because messages becomes the base for this step and every step after it, prepareStep doubles as a user-controlled compaction/rewrite hook — including system-prompt edits, which go through rewriting the system-role message in messages (there's no separate system field on this surface). A thrown prepareStep fails the call like any caller code: buffered calls reject, streaming calls get an error part plus rejected usage/finishReason — it is never silently swallowed.
Step-based model switch — run cheap exploration steps on a small model, then switch to a strong model for the final synthesis step:
import { generateText, type PrepareStepResult } from '@deuz-sdk/core';
const haiku = anthropic('claude-haiku-5'); // cheap, for the search/gather steps
const opus = anthropic('claude-opus-4-8'); // strong, for the final report
const result = await generateText({
model: haiku, // the loop's default model
messages: [{ role: 'user', content: 'Research, then write the final report.' }],
maxSteps: 10,
tools: { search, draft, finalize },
prepareStep: ({ stepIndex, usage }): PrepareStepResult | undefined => {
// Early steps: cheap model, search only. Once enough context is gathered,
// switch to the strong model and allow it to draft/finalize.
if (stepIndex < 3) return { model: haiku, activeTools: ['search'] };
return { model: opus, activeTools: ['draft', 'finalize'] };
},
});The returned model applies to that step; return it every step (as above) to keep it, or only on the steps you want to switch. prepareStep is a plain call option on the free functions — no agent class to instantiate — and it composes with automatic compaction: compaction runs first each step, then prepareStep sees the already-compacted history and can still rewrite messages itself.
Budget stop conditions
Alongside stepCountIs/hasToolCall (now also exported for convenience), two new StopCondition factories bound the loop by real spend instead of step count — exported from the root and /edge:
function totalTokensExceed(n: number): StopCondition;
function costExceeds(usd: number): StopCondition;totalTokensExceed(n)stops the loop once cumulative real usage (usage.totalTokens, summed across all steps — sub-agents included) reachesn. It reads provider-reported usage, never an estimate.costExceeds(usd)stops once cumulative cost reachesusd. It needsdeps.priceProviderto compute cost; without one it logs a warning once and the condition simply never fires (the loop keeps running under its other stop conditions).
Both OR into stopWhen exactly like a hand-written predicate, alongside the implicit maxSteps bound, and are evaluated at the step boundary — never mid-tool-batch (the same guard that keeps Anthropic from 400ing on an unanswered tool_use).
import { generateText, totalTokensExceed, costExceeds } from '@deuz-sdk/core';
const result = await generateText({
model: anthropic('claude-opus-4-8'),
messages: [{ role: 'user', content: 'Research this topic exhaustively.' }],
maxSteps: 50,
tools: { search, /* … */ },
stopWhen: [totalTokensExceed(100_000), costExceeds(2.0)], // whichever fires first
deps: { priceProvider: myPriceProvider }, // required for costExceeds to ever fire
});A budget stop does not change finishReason — the FinishReason union is locked, so it stays whatever the model actually returned (typically 'tool_calls'). Instead, the result (and the streaming finish part) carries a new additive field naming what stopped it:
result.providerMetadata?.deuz.stoppedBy; // 'totalTokensExceed' | 'costExceeds' when a budget stop firedGenerateTextResult.providerMetadata is itself a new additive field (Record<string, Record<string, unknown>>) — safe to ignore if you don't use budget stops.
Token- and cost-budget stops are first-class here: they're StopCondition factories like stepCountIs/hasToolCall, they read real provider-reported usage across every step and sub-agent, and the stoppedBy marker tells you afterward whether a budget bound (rather than the model) ended the loop — so you don't have to poll usage inside a hand-written predicate or guess why it stopped.
Controlling tool use with toolChoice
toolChoice steers whether and which tool the model calls. Each adapter maps it to its wire form:
| Value | Behavior |
|---|---|
'auto' | Model decides (default). |
'required' | Model must call some tool. |
'none' | Model must not call a tool. |
{ type: 'tool', toolName: 'getWeather' } | Force a specific tool. |
const result = await generateText({
model: anthropic('claude-opus-4-8'),
messages: [{ role: 'user', content: 'Weather in Paris?' }],
maxSteps: 5,
toolChoice: { type: 'tool', toolName: 'getWeather' },
tools: { getWeather: { parameters: z.object({ city: z.string() }), execute: async ({ city }) => ({ city }) } },
});See also
- generateText — buffered runs and the result/step shapes.
- streamChat — stream tool calls and results as they happen.
- Tool loop — the agentic loop's invariants: immutable history, parallel execution, runaway guards, and the Gemini stop-bug guard.
- generateObject — structured output, which reuses the same schema bridge.
- Context compaction — automatic layered history trimming; runs before
prepareStepon every step. - Sub-agents —
agentToolwraps a whole nested loop (with its ownprepareStep/activeTools/stop conditions) as a callableTool.