Deuz SDK
Agents

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 kindHow it runsShapeExample
Server function toolThe SDK runs your executeexecute set, type omittedgetWeather
Client toolNo execute — the loop breaks and the UI does a round-tripexecute omittedconfirmPurchase (see Client tools)
Provider toolThe model provider runs it during the turntype: 'provider' + providerToolanthropicWebSearch(), openaiWebSearch(), googleSearch() (see Provider-executed tools)
Sub-agent toolA nested agentic loop runsbuilt 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.

FieldTypeRequiredNotes
descriptionstringnoWhat the tool does. The model uses this to decide when to call it — write it carefully.
parametersStandardSchemaV1 or JSONSchemayesThe argument schema. A Standard Schema (Zod, Valibot, …) or a raw JSON Schema.
execute(args, ctx) => Result | Promise<Result>noRuns the tool server-side. Omit it for a client tool (see below).
needsApprovalboolean or predicatenoGates 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.
outputSchemaJSONSchemanoExpected 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.

server-mode-approval.ts
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.

json-schema-tool.ts
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:

zod-tool.ts
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, actionable InvalidRequestError. 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;
}
FieldTypeNotes
toolCallIdstringThe provider-assigned id for this specific call. Useful for logging/tracing.
messagesMessage[]Immutable snapshot of the conversation so far — read it for context, do not mutate it.
signalAbortSignal (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:

abortable-tool.ts
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_result

The 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:

  • execute throws — the loop catches it (via ToolExecutionError) and sends isError: true with the error message as the result.
  • A Standard Schema rejects the model's argumentsexecute is skipped and an Invalid 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:

prepare-step-model-switch.ts
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) reaches n. It reads provider-reported usage, never an estimate.
  • costExceeds(usd) stops once cumulative cost reaches usd. It needs deps.priceProvider to 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).

budget-stop.ts
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 fired

GenerateTextResult.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:

ValueBehavior
'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 prepareStep on every step.
  • Sub-agentsagentTool wraps a whole nested loop (with its own prepareStep/activeTools/stop conditions) as a callable Tool.

On this page