Skip to content

Defining Tools

Tools are functions that agents can call to interact with the world beyond generating text. They're how agents search the web, query databases, call APIs, or perform any action.

Basic Tool Definition

Use defineTool() to create a tool:

typescript
import { defineTool } from '@helix-agents/sdk';
import { z } from 'zod';

const searchTool = defineTool({
  name: 'search',
  description: 'Search the web for information on a topic',
  inputSchema: z.object({
    query: z.string().describe('The search query'),
    maxResults: z.number().default(5).describe('Maximum results to return'),
  }),
  outputSchema: z.object({
    results: z.array(
      z.object({
        title: z.string(),
        url: z.string(),
        snippet: z.string(),
      })
    ),
  }),
  execute: async (input, context) => {
    // input is typed as { query: string; maxResults: number }
    const results = await performSearch(input.query, input.maxResults);
    return { results };
  },
});

Configuration Reference

name (required)

Unique identifier for the tool. The LLM uses this to call the tool.

typescript
const tool = defineTool({
  name: 'get_weather', // Snake_case is common convention
  // ...
});

Naming conventions:

  • Use descriptive, action-oriented names: search_web, create_file, send_email
  • Avoid generic names like do_thing or helper
  • Keep names concise but clear

description (required)

Tells the LLM what the tool does and when to use it. This is critical for proper tool selection.

typescript
const tool = defineTool({
  name: 'search',
  description: `Search the web for current information.
Use this when you need:
- Recent news or events
- Factual information you're unsure about
- Information that may have changed since your training

Returns titles, URLs, and snippets from relevant pages.`,
  // ...
});

Tips for good descriptions:

  • Explain the purpose and use cases
  • Describe what the tool returns
  • Mention limitations or when NOT to use it
  • Be specific about expected inputs

inputSchema (required)

Zod schema defining what arguments the LLM must provide. The LLM sees the schema structure and descriptions.

typescript
const tool = defineTool({
  name: 'create_task',
  inputSchema: z.object({
    // Required fields
    title: z.string().min(1).describe('Task title (required)'),

    // Optional fields with defaults
    priority: z.enum(['low', 'medium', 'high']).default('medium').describe('Task priority level'),

    // Optional without default
    dueDate: z.string().optional().describe('Due date in ISO format (optional)'),

    // Arrays
    tags: z.array(z.string()).default([]).describe('Tags for categorization'),

    // Nested objects
    assignee: z
      .object({
        id: z.string(),
        name: z.string(),
      })
      .optional()
      .describe('Person to assign the task to'),
  }),
  // ...
});

Important: Use .describe() on fields - these descriptions help the LLM provide correct values.

outputSchema

Optional Zod schema for the tool's return value. Used for validation and documentation.

typescript
const tool = defineTool({
  name: 'analyze',
  inputSchema: z.object({ text: z.string() }),
  outputSchema: z.object({
    sentiment: z.enum(['positive', 'negative', 'neutral']),
    confidence: z.number().min(0).max(1),
    keywords: z.array(z.string()),
  }),
  execute: async (input) => {
    // Return type must match outputSchema
    return {
      sentiment: 'positive',
      confidence: 0.95,
      keywords: ['good', 'excellent'],
    };
  },
});

execute (required)

The function that runs when the LLM calls the tool. Receives validated input and a context object.

typescript
const tool = defineTool({
  name: 'example',
  inputSchema: z.object({ query: z.string() }),
  execute: async (input, context) => {
    // input: validated against inputSchema
    // context: ToolContext with state access, streaming, etc.

    console.log(`Query: ${input.query}`);
    console.log(`Agent ID: ${context.agentId}`);

    return { result: 'success' };
  },
});

Tool Context API

The context parameter provides access to agent state and streaming capabilities.

context.agentId

The unique run ID of the agent executing this tool.

typescript
execute: async (input, context) => {
  console.log(`Executing in agent: ${context.agentId}`);
  // ...
};

context.agentType

The type name of the agent (from defineAgent({ name: '...' })).

typescript
execute: async (input, context) => {
  console.log(`Agent type: ${context.agentType}`);
  // ...
};

context.abortSignal

Signal for cancellation. Check this in long-running operations:

typescript
execute: async (input, context) => {
  for (const item of items) {
    // Check for cancellation
    if (context.abortSignal.aborted) {
      throw new Error('Tool execution cancelled');
    }

    await processItem(item);
  }

  return { processed: items.length };
};

context.getState<T>()

Read the current custom state. Returns a read-only snapshot.

typescript
execute: async (input, context) => {
  // Type parameter should match your stateSchema
  const state = context.getState<{
    searchCount: number;
    findings: string[];
  }>();

  console.log(`Previous searches: ${state.searchCount}`);

  // Don't modify the returned object - use updateState instead
  return { previousSearches: state.searchCount };
};

context.updateState<T>(updater)

Modify custom state using Immer's draft pattern. Mutate the draft directly - Immer handles immutability.

typescript
execute: async (input, context) => {
  // Update state by mutating the draft
  context.updateState<{
    searchCount: number;
    findings: string[];
    lastQuery: string;
  }>((draft) => {
    draft.searchCount++;
    draft.findings.push(`Found info about: ${input.query}`);
    draft.lastQuery = input.query;
  });

  return { success: true };
};

Immer draft pattern:

  • Mutate draft directly (no spread operators needed)
  • Changes are tracked and applied immutably
  • Call updateState multiple times if needed
typescript
// Multiple updates are fine
context.updateState<State>((draft) => {
  draft.counter++;
});

// Later in the same execute function
context.updateState<State>((draft) => {
  draft.items.push(newItem);
});

context.emit(eventName, data)

Emit custom events to the stream. Use for progress updates, notifications, or domain-specific events.

typescript
execute: async (input, context) => {
  // Emit progress updates
  await context.emit('progress', { step: 1, total: 3, message: 'Starting...' });

  await doStep1();
  await context.emit('progress', { step: 2, total: 3, message: 'Processing...' });

  await doStep2();
  await context.emit('progress', { step: 3, total: 3, message: 'Finishing...' });

  await doStep3();

  // Domain-specific events
  await context.emit('file_uploaded', {
    filename: 'report.pdf',
    size: 1024,
    url: 'https://...',
  });

  return { success: true };
};

Consumers see these as custom stream chunks:

typescript
for await (const chunk of stream) {
  if (chunk.type === 'custom' && chunk.eventName === 'progress') {
    console.log(`Progress: ${chunk.data.step}/${chunk.data.total}`);
  }
}

Error Handling

Throwing Errors

Throw errors to indicate tool failure. The error message is sent back to the LLM:

typescript
execute: async (input, context) => {
  const response = await fetch(`https://api.example.com/data/${input.id}`);

  if (!response.ok) {
    if (response.status === 404) {
      throw new Error(`Item ${input.id} not found. Try a different ID.`);
    }
    throw new Error(`API error: ${response.status}. Please try again.`);
  }

  return await response.json();
};

The LLM receives the error message and can decide how to proceed (retry, try different approach, inform user).

Graceful Degradation

For non-critical failures, return a result indicating the issue:

typescript
execute: async (input, context) => {
  try {
    const data = await fetchData(input.query);
    return { success: true, data };
  } catch (error) {
    // Return graceful failure instead of throwing
    return {
      success: false,
      error: error.message,
      suggestion: 'Try a more specific query',
    };
  }
};

Best Practices

1. Keep Tools Focused

Each tool should do one thing well:

typescript
// Good: Focused tools
const searchTool = defineTool({ name: 'search' /* ... */ });
const saveTool = defineTool({ name: 'save_note' /* ... */ });

// Avoid: Kitchen sink tools
const doEverythingTool = defineTool({
  name: 'do_action',
  inputSchema: z.object({
    action: z.enum(['search', 'save', 'delete', 'update']),
    // ...
  }),
});

2. Provide Clear Descriptions

The LLM relies on descriptions to choose tools:

typescript
// Good: Clear, specific description
const tool = defineTool({
  name: 'get_stock_price',
  description: `Get the current stock price for a given ticker symbol.
Returns the latest price, daily change, and trading volume.
Only works for US stocks (NYSE, NASDAQ).
Use search_company first if you only have a company name.`,
  // ...
});

// Avoid: Vague description
const tool = defineTool({
  name: 'stock',
  description: 'Get stock data',
  // ...
});

3. Validate Inputs Thoroughly

Use Zod's validation features:

typescript
const tool = defineTool({
  name: 'send_email',
  inputSchema: z.object({
    to: z.string().email().describe('Recipient email address'),
    subject: z.string().min(1).max(200).describe('Email subject'),
    body: z.string().max(10000).describe('Email body'),
  }),
  // ...
});

4. Handle Cancellation

Respect the abort signal in long operations:

typescript
execute: async (input, context) => {
  const results = [];

  for (const url of input.urls) {
    if (context.abortSignal.aborted) {
      return { results, partial: true, reason: 'cancelled' };
    }

    const data = await fetch(url, { signal: context.abortSignal });
    results.push(await data.json());
  }

  return { results, partial: false };
};

5. Use State for Coordination

Track state when tools need to coordinate:

typescript
const searchTool = defineTool({
  name: 'search',
  execute: async (input, context) => {
    // Check if we've searched too much
    const state = context.getState<{ searchCount: number }>();
    if (state.searchCount >= 10) {
      throw new Error('Search limit reached. Please summarize findings.');
    }

    // Track the search
    context.updateState<{ searchCount: number }>((draft) => {
      draft.searchCount++;
    });

    return await performSearch(input.query);
  },
});

6. Emit Progress for Long Operations

Keep users informed:

typescript
execute: async (input, context) => {
  const files = await listFiles(input.directory);

  for (let i = 0; i < files.length; i++) {
    await context.emit('processing', {
      current: i + 1,
      total: files.length,
      file: files[i].name,
    });

    await processFile(files[i]);
  }

  return { processed: files.length };
};

Complete Example

typescript
import { defineTool } from '@helix-agents/sdk';
import { z } from 'zod';

interface SearchState {
  searchCount: number;
  queries: string[];
}

const webSearchTool = defineTool({
  name: 'web_search',

  description: `Search the web for current information.
Use when you need recent or factual information.
Returns up to 10 results with titles, URLs, and snippets.
For news, add "news" to your query.`,

  inputSchema: z.object({
    query: z.string().min(1).max(200).describe('Search query - be specific for better results'),
    type: z.enum(['web', 'news', 'images']).default('web').describe('Type of search to perform'),
  }),

  outputSchema: z.object({
    results: z.array(
      z.object({
        title: z.string(),
        url: z.string(),
        snippet: z.string(),
      })
    ),
    totalResults: z.number(),
  }),

  execute: async (input, context) => {
    // Check abort signal
    if (context.abortSignal.aborted) {
      throw new Error('Search cancelled');
    }

    // Track in state
    context.updateState<SearchState>((draft) => {
      draft.searchCount++;
      draft.queries.push(input.query);
    });

    // Emit start event
    await context.emit('search_started', { query: input.query });

    try {
      // Perform search (mock implementation)
      const response = await fetch(
        `https://api.search.example/search?q=${encodeURIComponent(input.query)}&type=${input.type}`,
        { signal: context.abortSignal }
      );

      if (!response.ok) {
        throw new Error(`Search failed: ${response.status}`);
      }

      const data = await response.json();

      // Emit completion event
      await context.emit('search_completed', {
        query: input.query,
        resultCount: data.results.length,
      });

      return {
        results: data.results.slice(0, 10),
        totalResults: data.totalResults,
      };
    } catch (error) {
      // Emit error event
      await context.emit('search_failed', {
        query: input.query,
        error: error.message,
      });
      throw error;
    }
  },
});

Next Steps

Released under the MIT License.