Crux
GuidesTools

Tools

Declare tools on contexts and prompts, merged automatically by adapters.

Tools are only available in text mode (prompts without an output schema). Structured output prompts use the model's JSON mode instead of tool calling.

Tools from three sources are merged with last-write-wins precedence:

  1. Context tools — from context({ tools }) via use
  2. Prompt tools — from prompt({ tools })
  3. Call-site tools — from generate(prompt, { tools })
agent.ts
import { prompt, context } from '@crux/core'
import { generate, tool, stepCountIs } from '@crux/ai'

const search = context({
  id: 'search',
  system: '## Search\nUse searchWeb to find information.',
  tools: {
    searchWeb: tool({
      description: 'Search the web',
      parameters: z.object({ query: z.string() }),
      execute: async ({ query }) => ({ results: [] }),
    }),
  },
})

const agent = prompt({
  use: [search],
  input: z.object({ task: z.string() }),
  system: 'You are a research assistant.',
  prompt: ({ input }) => input.task,
  tools: {
    saveNote: tool({
      description: 'Save a research note',
      parameters: z.object({ note: z.string() }),
      execute: async ({ note }) => ({ saved: true }),
    }),
  },
  stopWhen: stepCountIs(5),
})

// Both searchWeb and saveNote are available to the model
const result = await generate(agent, {
  model,
  input: { task: 'Research AI trends' },
})

// Override at call site
const result2 = await generate(agent, {
  model,
  input: { task: 'Research AI trends' },
  tools: { extraTool: tool({ ... }) },
  activeTools: ['searchWeb', 'saveNote'],
})

Shape what the model sees

Tools often return data that is useful for your app but too large, noisy, or unsafe to feed back into the model unchanged. Use toModelOutput() when the raw result and the model-facing result should be different.

search-tool.ts
import { tool } from '@crux/ai'
import { z } from 'zod'

const searchDocs = tool({
  description: 'Search product documentation',
  parameters: z.object({ query: z.string() }),
  execute: async ({ query }) => {
    const hits = await searchIndex(query)

    // Your app, logs, devtools, and evals can still inspect this full result.
    return {
      query,
      hits,
      debug: {
        indexLatencyMs: 18,
        rawScores: hits.map((hit) => hit.score),
      },
    }
  },
  toModelOutput: ({ output }) => ({
    type: 'text',
    value: output.hits
      .slice(0, 5)
      .map((hit) => `[${hit.sourceId}] ${hit.title}\n${hit.snippet}`)
      .join('\n\n'),
  }),
})

Crux preserves both shapes:

const result = await generate(agent, {
  model,
  input: { task: 'Find enterprise SSO docs' },
  tools: { searchDocs },
})

result.toolResults[0].output      // full raw object from execute()
// The next model step receives the compact text from toModelOutput().

Return json when the model should receive structured data:

const getInvoice = tool({
  description: 'Fetch an invoice',
  parameters: z.object({ invoiceId: z.string() }),
  execute: async ({ invoiceId }) => loadInvoice(invoiceId),
  toModelOutput: ({ output }) => ({
    type: 'json',
    value: {
      id: output.id,
      status: output.status,
      total: output.total,
      currency: output.currency,
    },
  }),
})

Use explicit error outputs when the tool handled a failure and you want the model to recover:

const readFile = tool({
  description: 'Read a project file',
  parameters: z.object({ path: z.string() }),
  execute: async ({ path }) => readProjectFile(path),
  toModelOutput: ({ output }) =>
    output.allowed
      ? { type: 'text', value: output.contents }
      : { type: 'execution-denied', reason: output.reason },
})

Return content when the model should receive multimodal tool output:

const renderChart = tool({
  description: 'Render a chart for the current report',
  parameters: z.object({ chartId: z.string() }),
  execute: async ({ chartId }) => renderChartImage(chartId),
  toModelOutput: ({ output }) => ({
    type: 'content',
    value: [
      { type: 'text', text: output.caption },
      { type: 'image-data', mediaType: 'image/png', data: output.base64Png },
    ],
  }),
})

If toModelOutput() is omitted, Crux uses the standard default: strings become { type: 'text' }, everything else becomes JSON. @crux/ai passes model output through to the AI SDK. Native adapters preserve supported media natively where the provider allows it: Google uses function-response media parts, Anthropic uses native tool_result content blocks, and OpenAI Chat Completions receives deterministic text references for non-text parts because that API only accepts text tool results.

Tool merging order

Tools are collected from three layers and merged with last-write-wins semantics. If two layers define a tool with the same name, the later layer's definition replaces the earlier one entirely.

LayerSourcePrecedence
1. Context toolscontext({ tools }) collected via useLowest
2. Prompt toolsprompt({ tools })Middle
3. Call-site toolsgenerate(prompt, { tools })Highest

Internally, the resolve pipeline calls collectContextTools() to gather tools from all contexts (iterating through the use array in order, with later contexts overwriting earlier ones on name collision). Then prompt-level config.tools are merged on top via Object.assign. Finally, the adapter merges call-site tools last.

tools.ts
import { prompt, context } from '@crux/core'
import { generate, tool } from '@crux/ai'
import { z } from 'zod'

// Context provides a basic search tool
const webSearch = context({
  id: 'web-search',
  system: '## Web Search\nUse search to find information online.',
  tools: {
    search: tool({
      description: 'Basic web search',
      parameters: z.object({ query: z.string() }),
      execute: async ({ query }) => ({ results: [`Result for: ${query}`] }),
    }),
  },
})

// Prompt overrides search with a more specific implementation
const researcher = prompt({
  use: [webSearch],
  input: z.object({ topic: z.string() }),
  system: 'You are a research assistant.',
  prompt: ({ input }) => `Research: ${input.topic}`,
  tools: {
    // This replaces the context's search tool (same name)
    search: tool({
      description: 'Academic search with citations',
      parameters: z.object({ query: z.string(), year: z.number().optional() }),
      execute: async ({ query, year }) => ({
        results: [{ title: query, year, citation: '...' }],
      }),
    }),
  },
})

// Call-site can add more tools or override again
const result = await generate(researcher, {
  model,
  input: { topic: 'quantum computing' },
  tools: {
    // Adds a new tool at call-site (highest precedence)
    saveResult: tool({
      description: 'Persist a research finding',
      parameters: z.object({ finding: z.string() }),
      execute: async ({ finding }) => ({ saved: true }),
    }),
  },
})

Contexts whose system text was dropped due to a token budget still contribute their tools. Tool availability is independent of whether the context's system message fit within the budget.

activeTools

Use activeTools to restrict which tools the model can call on a given invocation, without removing their definitions. This is an adapter-level feature passed through to the Vercel AI SDK's generateText options.

const result = await generate(researcher, {
  model,
  input: { topic: 'quantum computing' },
  activeTools: ['search'],  // model can only use search, not saveNote
})

This is useful when the same prompt is used in different stages of a workflow where only a subset of tools should be available:

// Stage 1: research (only search)
const research = await generate(agent, {
  model,
  input: { task: 'Find pricing data' },
  activeTools: ['search'],
})

// Stage 2: writing (only save)
const written = await generate(agent, {
  model,
  input: { task: 'Write the report' },
  activeTools: ['saveNote'],
})

Middleware and approvals

Stop conditions

Control when multi-step tool use terminates using stopWhen. Crux re-exports the stepCountIs and hasToolCall helpers from the Vercel AI SDK:

import { generate, tool, stepCountIs, hasToolCall } from '@crux/ai'

// Stop after a maximum of 10 tool-use steps
const result = await generate(agent, {
  model,
  input: { task: 'Analyze the dataset' },
  stopWhen: stepCountIs(10),
})

You can also define stopWhen at the prompt level so it applies to every call:

const agent = prompt({
  system: 'You are an analyst.',
  input: z.object({ task: z.string() }),
  prompt: ({ input }) => input.task,
  tools: { /* ... */ },
  stopWhen: stepCountIs(5),  // default for all generate() calls
})

The hasToolCall helper stops execution when a specific tool is called, which is useful for "exit" or "submit" tools:

const submitTool = tool({
  description: 'Submit the final answer',
  parameters: z.object({ answer: z.string() }),
  execute: async ({ answer }) => ({ submitted: true }),
})

const result = await generate(agent, {
  model,
  input: { task: 'Solve the puzzle' },
  tools: { submit: submitTool },
  stopWhen: hasToolCall('submit'),
})

Tool error handling

When a tool's execute function throws an error, the AI SDK catches it and returns the error as a tool result to the model. This allows the model to see what went wrong and retry with different arguments or adjust its approach.

const fetchPage = tool({
  description: 'Fetch a web page',
  parameters: z.object({ url: z.string().url() }),
  execute: async ({ url }) => {
    const res = await fetch(url)
    if (!res.ok) {
      throw new Error(`HTTP ${res.status}: ${res.statusText}`)
    }
    return { content: await res.text() }
  },
})

If execute throws, the model receives the error message as the tool result and can decide to try a different URL, ask the user for help, or move on. Combine this with stopWhen: stepCountIs(n) to prevent infinite retry loops.

Next steps

On this page