Complete guide for creating powerful, production-ready tools for Nous AI agents.
Quick Start
New to tool development? Start with:
- 02_with_tools.exs - A first tool in ~30 lines
- 07_module_tools.exs - Module tools with
Nous.Tool.Behaviour - 08_tool_testing.exs - Mock and spy tools with
Nous.Tool.Testing
Table of Contents
- Tool Fundamentals
- Function Signature
- Input Validation
- Error Handling
- Security Considerations
- Performance Guidelines
- Parallel Tool Calls
- Built-in Todo Tools
- 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
endParallel Tool Calls
When a single model response contains several tool calls, Nous executes them one
at a time by default: each call finishes its whole pipeline before the next one
starts. Setting parallel_tool_calls: true on the agent fans the executions out
instead.
agent =
Nous.new("openai:gpt-4o",
tools: [&MyTools.fetch_user/2, &MyTools.fetch_orders/2, &MyTools.fetch_invoices/2],
parallel_tool_calls: true
)- Default:
false.%Nous.Agent{}is built withparallel_tool_calls: falseand only flips when you pass the option toNous.new/2(Nous.Agent.new/2). - Applies when one response carries two or more real tool calls. A single call — or a response whose only calls are the synthetic ones used for structured output — takes the sequential path unchanged.
What runs concurrently, and what does not
Only the tool executions fan out. Every ordering-sensitive stage stays sequential, in the model's call order:
- Before the fan-out, per call: the
:on_tool_callcallback, the invalid-arguments short-circuit, the:pre_tool_usehook, and the permission/approval check. - Concurrently: the approved tool functions, supervised by
Nous.TaskSupervisor. - After the fan-out, in the original call order: the
:post_tool_usehook, the:on_tool_responsecallback, the agent behaviour's:after_tool, and the dependency merge.
Tool result messages are appended in the original call order, because providers require results to line up with the calls they answer.
When to enable it
Enable it when a turn's tools are independent and I/O-bound: separate HTTP APIs, separate database reads, unrelated file reads. A turn that issues three 900 ms lookups then costs about one lookup instead of three.
Leave it off for cheap, CPU-bound tools. Fanning out three map lookups buys nothing and adds process churn.
The footgun: interleaved side effects
Tools cannot observe each other's context updates within a turn in either mode
— the run context is snapshotted before the tool loop — so ctx is not what
changes. What changes is external side effects: HTTP requests, database
writes, file writes and shell commands issued by different calls in the same turn
now interleave. :pre_tool_use hooks also see the pre-turn context rather than
the post-processing effects of earlier calls in the same batch.
So if a turn's tools depend on sequential side effects — write_file then
run_tests, create_order then charge_card — keep the default. That
dependency is precisely why the feature is opt-in.
Timeouts and crashes
A parallel batch is bounded by a single per-call ceiling, taken as the largest budget in the batch:
- a tool declaring a positive
timeout:contributestimeout * (retries + 1)plus five seconds of headroom — a timeout raised insideNous.ToolExecutorgoes through the retry path, so every attempt may spend the full budget; - a tool that declares no positive timeout contributes the module default of five minutes, which you can override globally:
config :nous, parallel_tool_call_timeout_ms: :timer.minutes(2)A call that blows the ceiling is killed and answered with a timeout tool result for that call alone; a crashing call becomes a per-call tool error. Neither sinks the rest of the batch or the turn.
Built-in Todo Tools
Nous.Tools.TodoTools is the built-in task-tracking tool set. It lets an agent
break a multi-step job into todos, record progress, and keep the list in front of
itself instead of losing the plan halfway through a long run.
Not to be confused with the
TodoToolsmodule defined inline inexamples/advanced/context_updates.exs— that is a user-land, three-function demo ofNous.Tool.ContextUpdatethat happens to share the name. The built-in module isNous.Tools.TodoTools.
It is a plain function module (no Nous.Tool.Behaviour), so register the captures
you want:
alias Nous.Tools.TodoTools
agent =
Nous.new("openai:gpt-4o",
instructions: "Plan before you act, and keep the todo list current.",
enable_todos: true,
tools: [
&TodoTools.add_todo/2,
&TodoTools.update_todo/2,
&TodoTools.complete_todo/2,
&TodoTools.list_todos/2
]
)
{:ok, result} = Nous.run(agent, "Analyze this codebase and write a report", deps: %{todos: []})The five tools
| Function | Arguments | Notes |
|---|---|---|
add_todo/2 | "text" (also accepts "title" or "description"), optional "status", "priority" | Defaults to "pending" / "medium"; errors if the text is missing or empty |
update_todo/2 | "id", plus any of "text", "status", "priority" | Omitted fields are left alone |
complete_todo/2 | "id" | Sets status to "completed" and stamps completed_at |
delete_todo/2 | "id" | Removes the entry |
list_todos/2 | optional "status", "priority" filters | Also returns total and by_status counts |
Statuses are "pending", "in_progress" and "completed"; priorities are
"low", "medium" and "high". Each stored todo is a map with :id (a
process-unique positive integer), :text, :status, :priority, :created_at
and :updated_at ISO-8601 timestamps.
Where the state lives
The list lives in ctx.deps[:todos]. Every mutating tool returns the full updated
list under __update_context__, which the runner merges back into the run's
dependencies after the tool call — so the next model iteration sees it (tools in
the same response still see the pre-turn snapshot, as above).
Passing enable_todos: true additionally renders the todos from deps into the
system prompt when the run context is built, grouped by status and followed by a
short usage note for the four tools above. Continuing a previous run from its
returned context keeps that run's original prompt, so pass deps: %{todos: [...]}
on the run that creates the context.
Testing 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 02_with_tools.exs
- Move to module tools: Copy 07_module_tools.exs
- Study a production-shaped agent: Read 19_coding_agent.exs, which wires the built-in file and shell tools to permissions and session guardrails, or browse the examples index
- 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!