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:
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.
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_thingorhelper - 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.
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.
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.
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.
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.
execute: async (input, context) => {
console.log(`Executing in agent: ${context.agentId}`);
// ...
};context.agentType
The type name of the agent (from defineAgent({ name: '...' })).
execute: async (input, context) => {
console.log(`Agent type: ${context.agentType}`);
// ...
};context.abortSignal
Signal for cancellation. Check this in long-running operations:
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.
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.
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
draftdirectly (no spread operators needed) - Changes are tracked and applied immutably
- Call
updateStatemultiple times if needed
// 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.
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:
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:
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:
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:
// 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:
// 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:
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:
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:
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:
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
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
- State Management - Deep dive into state patterns
- Streaming - Handle real-time events
- Sub-Agents - Create tools that delegate to other agents