🔧 Tool Development Guide
View SourceComplete guide for creating powerful, production-ready tools for Nous AI agents.
Quick Start
New to tool development? Start with:
- custom_tools_guide.exs - Interactive tutorial
- templates/tool_agent.exs - Copy-paste starter
- by_feature/tools/ - Working examples
Table of Contents
- Tool Fundamentals
- Function Signature
- Input Validation
- Error Handling
- Security Considerations
- Performance Guidelines
- Testing Tools
- Advanced Patterns
- Production Deployment
Tool Fundamentals
What Are Tools?
Tools are Elixir functions that AI agents can call to perform actions:
- Access external APIs (weather, search, databases)
- Perform calculations (math, data processing)
- File operations (read, write, analyze)
- System interactions (shell commands, monitoring)
How They Work
- AI decides when to call tools based on user input
- Agent calls your function with structured arguments
- Function executes and returns results
- AI uses results to continue the conversation
# AI sees: "What's the weather in Paris?"
# AI calls: get_weather(%{"location" => "Paris"})
# Function returns: "Sunny, 22°C"
# AI responds: "The weather in Paris is sunny and 22°C"Function Signature
Standard Pattern
All tools must use this exact signature:
def my_tool(context, args) do
# Your implementation
endParameters
context - Runtime Context
%{
deps: %{ # Dependencies from caller
database: MyApp.Repo,
user_id: 123,
api_keys: %{...}
},
conversation_history: [...], # Previous messages
request_id: "req_123", # For logging/tracing
timestamp: ~U[...] # Request timestamp
}args - AI-Provided Arguments
%{
"parameter_name" => "value", # Always string keys
"optional_param" => "value",
# Note: AI determines these based on function name and usage
}Return Values
Success - Return Data Directly
# String
"Weather in Paris: Sunny, 22°C"
# Number
42
# Map/Struct
%{temperature: 22, conditions: "sunny", humidity: 65}
# List
["result1", "result2", "result3"]Failure - Return Error Tuple
{:error, "Weather API is unavailable"}
{:error, "Invalid location: #{location}"}
{:error, %{code: 404, message: "City not found"}}Input Validation
Required Parameters
def search_database(_ctx, %{"query" => query}) when is_binary(query) and query != "" do
# Implementation
end
def search_database(_ctx, args) do
{:error, "query parameter is required and must be a non-empty string"}
endType Validation with Guards
def calculate(_ctx, %{"operation" => op, "a" => a, "b" => b})
when is_number(a) and is_number(b) and op in ["add", "subtract", "multiply", "divide"] do
# Safe to proceed
perform_calculation(op, a, b)
end
def calculate(_ctx, args) do
{:error, "Invalid arguments: #{inspect(args)}"}
endComprehensive Validation
def robust_validator(_ctx, args) do
with {:ok, email} <- validate_email(args),
{:ok, age} <- validate_age(args),
{:ok, preferences} <- validate_preferences(args) do
# All validations passed
process_user_data(email, age, preferences)
else
{:error, reason} -> {:error, reason}
end
end
defp validate_email(%{"email" => email}) do
if String.contains?(email, "@") and String.contains?(email, ".") do
{:ok, email}
else
{:error, "Invalid email format"}
end
end
defp validate_email(_), do: {:error, "email parameter is required"}
defp validate_age(%{"age" => age}) when is_number(age) and age >= 0 and age <= 150 do
{:ok, age}
end
defp validate_age(_), do: {:error, "age must be a number between 0 and 150"}Error Handling
Error Categories
Validation Errors
{:error, "Parameter 'location' is required"}
{:error, "Invalid email format: #{email}"}
{:error, "Price must be a positive number"}External Service Errors
case HTTPoison.get(url) do
{:ok, %{status_code: 200, body: body}} ->
body
{:ok, %{status_code: 404}} ->
{:error, "Resource not found"}
{:ok, %{status_code: status}} ->
{:error, "HTTP #{status}: Request failed"}
{:error, %{reason: :timeout}} ->
{:error, "Request timeout - service may be slow"}
{:error, reason} ->
{:error, "Network error: #{inspect(reason)}"}
endSystem Errors
case File.read(filepath) do
{:ok, content} ->
content
{:error, :enoent} ->
{:error, "File not found: #{filepath}"}
{:error, :eacces} ->
{:error, "Permission denied: #{filepath}"}
{:error, reason} ->
{:error, "File error: #{reason}"}
endException Handling
def safe_tool(ctx, args) do
try do
risky_operation(args)
rescue
ArgumentError -> {:error, "Invalid arguments provided"}
RuntimeError -> {:error, "Operation failed"}
e -> {:error, "Unexpected error: #{Exception.message(e)}"}
catch
:throw, reason -> {:error, "Operation aborted: #{reason}"}
end
endSecurity Considerations
Input Sanitization
def secure_file_reader(_ctx, %{"filepath" => path}) do
# Prevent path traversal
if String.contains?(path, ["../", "..\\"]) do
{:error, "Path traversal not allowed"}
end
# Restrict to allowed directories
safe_base = "/allowed/directory"
if not String.starts_with?(Path.expand(path), safe_base) do
{:error, "Access denied: path outside safe directory"}
end
File.read(path)
endPermission Checks
def authorized_operation(ctx, args) do
user_permissions = get_user_permissions(ctx)
required_permission = :admin_access
if required_permission in user_permissions do
perform_sensitive_operation(args)
else
{:error, "Insufficient permissions: #{required_permission} required"}
end
end
defp get_user_permissions(ctx) do
ctx.deps[:user_permissions] || []
endRate Limiting
defmodule RateLimiter do
use GenServer
def check_rate_limit(user_id, limit_per_minute \\ 60) do
GenServer.call(__MODULE__, {:check_limit, user_id, limit_per_minute})
end
# Implementation details...
end
def rate_limited_tool(ctx, args) do
user_id = ctx.deps[:user_id]
case RateLimiter.check_rate_limit(user_id) do
:ok -> perform_operation(args)
{:error, :rate_limited} -> {:error, "Rate limit exceeded. Please try again later."}
end
endPerformance Guidelines
Response Time Limits
- Target: < 2 seconds for most tools
- Maximum: < 10 seconds (AI may timeout)
- Long operations: Use async patterns or streaming
Memory Usage
def memory_efficient_processor(_ctx, %{"data" => large_dataset}) do
# Process in chunks instead of loading everything
large_dataset
|> Stream.chunk_every(1000)
|> Stream.map(&process_chunk/1)
|> Enum.reduce([], &combine_results/2)
endCaching
defmodule ToolCache do
@ttl 300_000 # 5 minutes
def cached_api_call(url) do
case :ets.lookup(:tool_cache, url) do
[{^url, result, timestamp}] ->
if System.system_time(:millisecond) - timestamp < @ttl do
result
else
fetch_and_cache(url)
end
[] ->
fetch_and_cache(url)
end
end
defp fetch_and_cache(url) do
result = HTTPoison.get!(url).body
:ets.insert(:tool_cache, {url, result, System.system_time(:millisecond)})
result
end
endTesting Tools
Unit Testing
defmodule MyToolsTest do
use ExUnit.Case
describe "weather_tool/2" do
test "returns weather for valid location" do
ctx = %{}
args = %{"location" => "Paris"}
result = MyTools.weather_tool(ctx, args)
assert is_binary(result)
assert String.contains?(result, "Paris")
end
test "returns error for empty location" do
ctx = %{}
args = %{"location" => ""}
result = MyTools.weather_tool(ctx, args)
assert {:error, _reason} = result
end
test "handles missing location parameter" do
ctx = %{}
args = %{}
result = MyTools.weather_tool(ctx, args)
assert {:error, reason} = result
assert String.contains?(reason, "location")
end
end
endIntegration Testing
defmodule ToolIntegrationTest do
use ExUnit.Case
test "tool works with AI agent" do
agent = Nous.new("lmstudio:qwen3-vl-4b-thinking-mlx",
tools: [&MyTools.weather_tool/2]
)
{:ok, result} = Nous.run(agent, "What's the weather in Tokyo?")
assert String.contains?(result.output, "Tokyo")
assert result.usage.tool_calls > 0
end
endPerformance Testing
defmodule ToolBenchmark do
def benchmark_tool(tool_function, args, iterations \\ 100) do
ctx = %{}
{time_microseconds, _results} = :timer.tc(fn ->
Enum.map(1..iterations, fn _ ->
tool_function.(ctx, args)
end)
end)
avg_time_ms = time_microseconds / iterations / 1000
IO.puts("Average execution time: #{Float.round(avg_time_ms, 2)}ms")
end
endAdvanced Patterns
Tool Composition
def composite_research_tool(ctx, %{"topic" => topic}) do
with {:ok, search_results} <- search_web(ctx, %{"query" => topic}),
{:ok, summary} <- summarize_content(ctx, %{"content" => search_results}),
{:ok, questions} <- generate_questions(ctx, %{"summary" => summary}) do
%{
topic: topic,
summary: summary,
related_questions: questions,
timestamp: DateTime.utc_now()
}
else
{:error, reason} -> {:error, "Research failed: #{reason}"}
end
endContext-Aware Tools
def contextual_assistant(ctx, args) do
# Analyze conversation history
history = ctx.conversation_history || []
user_preferences = ctx.deps[:user_preferences] || %{}
# Adapt behavior based on context
response_style = determine_response_style(history, user_preferences)
# Generate contextual response
generate_response(args, response_style)
end
defp determine_response_style(history, preferences) do
cond do
length(history) < 3 -> :formal
Map.get(preferences, :style) == "casual" -> :casual
detect_technical_conversation(history) -> :technical
true -> :balanced
end
endStreaming Tools
def streaming_analysis_tool(ctx, args) do
# For tools that produce streaming output
# Note: This is a conceptual example - actual streaming
# implementation depends on Nous's streaming capabilities
{:stream, fn ->
# Yield partial results as they become available
Stream.unfold({:start, args}, fn
{:start, args} ->
result = perform_initial_analysis(args)
{{:partial, result}, {:continue, args}}
{:continue, args} ->
result = perform_detailed_analysis(args)
{{:final, result}, :done}
:done -> nil
end)
end}
endProduction Deployment
Environment Configuration
defmodule ProductionTools do
@api_key System.get_env("WEATHER_API_KEY") ||
raise "WEATHER_API_KEY environment variable is required"
@rate_limits %{
default: 100,
premium: 1000
}
def weather_service(ctx, args) do
user_tier = ctx.deps[:user_tier] || :default
rate_limit = @rate_limits[user_tier]
with :ok <- check_rate_limit(ctx.deps[:user_id], rate_limit),
{:ok, weather} <- fetch_weather_data(args, @api_key) do
format_weather_response(weather)
else
{:error, reason} -> {:error, reason}
end
end
endMonitoring and Logging
def monitored_tool(ctx, args) do
start_time = System.monotonic_time(:millisecond)
tool_name = "my_important_tool"
# Emit telemetry event for monitoring
:telemetry.execute([:nous, :tool, :start], %{}, %{
tool: tool_name,
user_id: ctx.deps[:user_id],
args: sanitize_args_for_logging(args)
})
result = try do
perform_tool_operation(args)
rescue
error ->
Logger.error("Tool #{tool_name} failed", error: error, args: args)
{:error, "Internal tool error"}
end
duration = System.monotonic_time(:millisecond) - start_time
:telemetry.execute([:nous, :tool, :complete], %{duration: duration}, %{
tool: tool_name,
status: elem(result, 0),
user_id: ctx.deps[:user_id]
})
result
end
defp sanitize_args_for_logging(args) do
# Remove sensitive data from logs
Map.drop(args, ["password", "api_key", "secret"])
endHealth Checks
def health_check_tool(_ctx, _args) do
checks = [
{:database, check_database_connection()},
{:api, check_external_api()},
{:cache, check_cache_system()},
{:disk_space, check_disk_space()}
]
failed_checks = Enum.filter(checks, fn {_name, status} -> status != :ok end)
if failed_checks == [] do
%{status: "healthy", timestamp: DateTime.utc_now()}
else
%{
status: "degraded",
failed_checks: failed_checks,
timestamp: DateTime.utc_now()
}
end
endCommon Patterns
File Processing Tool
def process_file(ctx, %{"filepath" => path, "operation" => op}) do
with :ok <- validate_file_access(ctx, path),
{:ok, content} <- File.read(path),
{:ok, result} <- apply_operation(op, content) do
result
else
{:error, reason} -> {:error, reason}
end
endDatabase Query Tool
def query_database(ctx, %{"query" => query, "params" => params}) do
repo = ctx.deps[:database]
case Ecto.Adapters.SQL.query(repo, query, params) do
{:ok, %{rows: rows}} -> format_query_results(rows)
{:error, reason} -> {:error, "Database error: #{inspect(reason)}"}
end
endAPI Integration Tool
def call_external_api(ctx, %{"endpoint" => endpoint, "data" => data}) do
api_key = ctx.deps[:api_key]
base_url = ctx.deps[:base_url]
case HTTPoison.post("#{base_url}/#{endpoint}", JSON.encode!(data), [
{"Authorization", "Bearer #{api_key}"},
{"Content-Type", "application/json"}
]) do
{:ok, %{status_code: 200, body: body}} -> JSON.decode!(body)
{:ok, %{status_code: status}} -> {:error, "API returned #{status}"}
{:error, reason} -> {:error, "Network error: #{inspect(reason)}"}
end
endBest Practices Summary
✅ Do This
- Validate all inputs thoroughly
- Use descriptive error messages
- Handle all failure modes gracefully
- Keep responses concise but informative
- Add comprehensive documentation
- Test tools independently
- Monitor performance and errors
- Follow security best practices
❌ Avoid This
- Returning complex nested structures
- Long-running operations without timeouts
- Ignoring error cases
- Exposing sensitive information in errors
- Performing dangerous operations without validation
- Blocking operations without async patterns
Next Steps
- Start with examples: Try custom_tools_guide.exs
- Use templates: Copy templates/tool_agent.exs
- Study production tools: Check trading_desk/
- Read related guides:
- best_practices.md - Production deployment
- troubleshooting.md - Common issues
- Join the community: Share your tools and learn from others
Remember: Great tools make great AI agents. Invest time in making them robust, secure, and user-friendly!