Tools Guide

In Atomic Agents, tools are not a magic parameter on the agent. This is the single most common point of confusion for users coming from frameworks like LangChain, CrewAI, or PydanticAI, where you would write:

# ❌ This is NOT how Atomic Agents works
agent = Agent(tools=[calculator, search])

There is no tools=[...] argument anywhere in the framework, and that is intentional. This guide explains the philosophy and shows the two patterns you will use in practice.

Philosophy: tools are atomic components, not framework citizens

A tool in Atomic Agents is just an object with:

  • A typed input_schema (a BaseIOSchema)

  • A typed output_schema (a BaseIOSchema)

  • A run() method that takes one and returns the other

It does not know about agents, prompts, memory, or any LLM. You decide when to call it. That control is the whole point — you can read the call site, set a breakpoint on it, and reason about cost and latency the same way you reason about any other function call.

This buys you three things other frameworks struggle with:

  1. Determinism where you want it. If the next step is “always run the search tool,” you just call it. No LLM, no prompt overhead, no chance of the model deciding to skip it.

  2. A real call graph. Tools are functions. Stack traces, profiler output, and code search work normally. There is no opaque agent loop hiding the dispatch.

  3. No coupling. A tool is reusable in non-agent code. The same CalculatorTool instance works in a script, a FastAPI handler, or a unit test, with no agent involved.

The two patterns

In practice, every tool call in Atomic Agents falls into one of two patterns. Pick based on whether you know which tool to call, or whether the LLM needs to decide.

Pattern 1: Direct call (you know which tool to use)

When the workflow is fixed — “first generate a query, then run the search, then summarize” — call the tool directly. This is the default. It’s faster, cheaper, more debuggable, and harder for an LLM to derail.

from atomic_agents import AtomicAgent, AgentConfig
from my_tools.search import SearXNGSearchTool, SearXNGSearchToolConfig

# 1. Agent generates structured search queries.
#    Notice: query_agent's output_schema IS SearXNGSearchTool's input_schema.
query_agent = AtomicAgent[QueryAgentInputSchema, SearXNGSearchTool.input_schema](
    AgentConfig(client=client, model="gpt-4o-mini", ...)
)

# 2. Tool is just an object you instantiate.
search_tool = SearXNGSearchTool(config=SearXNGSearchToolConfig(base_url="..."))

# 3. You wire them together with normal Python — no framework glue.
queries = query_agent.run(QueryAgentInputSchema(instruction="Find recent papers on..."))
results = search_tool.run(queries)  # output of agent IS input of tool

The schema alignment between query_agent’s output_schema and SearXNGSearchTool.input_schema is what makes this composable: the agent literally cannot produce something the tool cannot accept, because they share the same Pydantic schema.

Use this pattern when:

  • The order of operations is known at build time.

  • You care about latency and cost (no extra LLM call to “decide”).

  • You want the call site to show up in stack traces and code search.

  • The tool is non-optional — skipping it would be a bug.

Pattern 2: Choice agent (LLM picks the tool)

When the workflow genuinely depends on the user’s input — “if it’s math, use the calculator; if it’s a fact lookup, search the web” — let an LLM pick. The mechanism is a normal agent whose output_schema is a Union of tool input schemas. Instructor will validate the model’s response against the union, so the agent can only return well-formed input for one of your tools.

from typing import Union
from pydantic import Field
from atomic_agents import AtomicAgent, AgentConfig, BaseIOSchema
from atomic_agents.context import SystemPromptGenerator

from my_tools.search import SearXNGSearchToolInputSchema
from my_tools.calculator import CalculatorToolInputSchema


class OrchestratorInput(BaseIOSchema):
    """User's question."""
    chat_message: str = Field(..., description="The user's input message.")


class OrchestratorOutput(BaseIOSchema):
    """Orchestrator picks ONE tool input schema from the union."""
    tool_parameters: Union[SearXNGSearchToolInputSchema, CalculatorToolInputSchema] = Field(
        ..., description="Parameters for the selected tool."
    )


orchestrator = AtomicAgent[OrchestratorInput, OrchestratorOutput](
    AgentConfig(
        client=client,
        model="gpt-4o-mini",
        system_prompt_generator=SystemPromptGenerator(
            background=[
                "You route the user's request to the right tool.",
                "Use the search tool for factual questions and current events.",
                "Use the calculator for mathematical expressions.",
            ],
            output_instructions=[
                "Return only the parameters for the chosen tool.",
            ],
        ),
    )
)

# YOU still dispatch on the type the LLM picked — there's no hidden routing.
result = orchestrator.run(OrchestratorInput(chat_message=user_input))

if isinstance(result.tool_parameters, SearXNGSearchToolInputSchema):
    tool_output = search_tool.run(result.tool_parameters)
elif isinstance(result.tool_parameters, CalculatorToolInputSchema):
    tool_output = calculator_tool.run(result.tool_parameters)

The isinstance dispatch is deliberate. It keeps tool selection visible and traceable — adding a tool means adding a Union member, a system-prompt line, and an isinstance branch, all in one file.

Use this pattern when:

  • The tool to call genuinely depends on natural-language input.

  • The set of candidate tools is small (a handful, not dozens — Union grows the prompt).

  • You want the LLM’s reasoning for the choice to be inspectable (extend the output schema with a reasoning: str field).

A complete, runnable version of this pattern lives in atomic-examples/orchestration-agent. The Orchestration guide covers tool-selection, multi-agent pipelines, dynamic routing, and parallel execution in more depth.

Picking a pattern

Question

Pattern 1 (Direct)

Pattern 2 (Choice agent)

Is the next tool always the same?

Does the choice depend on free-form user input?

Latency budget tight?

(extra LLM round-trip)

Want full debuggability?

(still good — choice is in the schema)

Tool is required for correctness?

Tool set growing past ~5–7?

(still works)

(consider hierarchical routing instead)

When in doubt, start with Pattern 1. Add a choice agent only when you actually have a routing problem that input data can’t answer.

The Atomic Forge: where tools live

Tools themselves are distributed via the Atomic Forge — a registry of standalone, modular tool packages that you download into your project. The Forge approach gives you:

  1. Full Control: You own the tool’s source. Modify behavior locally without forking the framework.

  2. Dependency Management: Tools live in your codebase, so their dependencies are yours to pin.

  3. Lightweight: Download only what you use. No Sympy unless you use the calculator; no requests unless you use a search tool.

Available tools

The Atomic Forge ships with several pre-built tools:

  • arXiv Search: Search arXiv for academic papers (free public API)

  • BoCha Search: Web search

  • Calculator: Perform mathematical calculations

  • DateTime: Timezone-aware now / parse / convert / shift / diff (no key required)

  • Fía Signals: Crypto market intelligence — market regime, trading signals, DeFi yields, gas prices, Solana trending tokens, and wallet risk scoring

  • Hacker News Search: Search HN stories, comments, Show HN, Ask HN, polls (free Algolia API)

  • PDF Reader: Extract text and metadata from local or remote PDFs, with page-range filtering

  • SearXNG Search: Search the web using SearXNG

  • Tavily Search: AI-powered web search

  • Weather: Current conditions and daily/hourly forecasts via Open-Meteo (no key required)

  • Webpage Scraper: Extract content from web pages

  • Wikipedia Search: Search Wikipedia in any language edition (no key required)

  • YouTube Transcript Scraper: Extract transcripts from YouTube videos

Downloading a tool

Use the Atomic Assembler CLI to download tools into your project:

atomic

This presents a menu to select and download tools. Each tool ships with input/output schemas, usage examples, dependencies, and installation instructions.

Tool layout

Each downloaded tool follows a standard structure:

tool_name/
│   .coveragerc
│   pyproject.toml
│   README.md
│   requirements.txt
│   uv.lock
│
├── tool/
│   │   tool_name.py
│   │   some_util_file.py
│
└── tests/
    │   test_tool_name.py
    │   test_some_util_file.py

Calling a downloaded tool

Once a tool is in your project, it’s just a Python class:

from calculator.tool.calculator import (
    CalculatorTool,
    CalculatorInputSchema,
    CalculatorToolConfig,
)

calculator = CalculatorTool(config=CalculatorToolConfig())

result = calculator.run(CalculatorInputSchema(expression="2 + 2"))
print(f"Result: {result.value}")  # Result: 4

This is Pattern 1 in its simplest form: you call .run() directly, no agent involved. The tool is reusable in any Python context — agent, script, test, web handler.

Creating custom tools

Build your own tool by subclassing BaseTool with input/output schemas and a config.

Basic structure

import os
from pydantic import Field
from atomic_agents import BaseTool, BaseToolConfig, BaseIOSchema

################
# Input Schema #
################

class MyToolInputSchema(BaseIOSchema):
    """Define what your tool accepts as input."""
    value: str = Field(..., description="Input value to process")

#####################
# Output Schema(s)  #
#####################

class MyToolOutputSchema(BaseIOSchema):
    """Define what your tool returns."""
    result: str = Field(..., description="Processed result")

#################
# Configuration #
#################

class MyToolConfig(BaseToolConfig):
    """Tool configuration options."""
    api_key: str = Field(
        default=os.getenv("MY_TOOL_API_KEY"),
        description="API key for the service",
    )

#####################
# Main Tool & Logic #
#####################

class MyTool(BaseTool[MyToolInputSchema, MyToolOutputSchema]):
    """Main tool implementation."""
    input_schema = MyToolInputSchema
    output_schema = MyToolOutputSchema

    def __init__(self, config: MyToolConfig = MyToolConfig()):
        super().__init__(config)
        self.api_key = config.api_key

    def run(self, params: MyToolInputSchema) -> MyToolOutputSchema:
        result = self.process_input(params.value)
        return MyToolOutputSchema(result=result)

Best practices

  • Single responsibility: Each tool should do one thing well.

  • Clear interfaces: Use explicit input/output schemas with Field(..., description=...) — those descriptions become the LLM’s prompt when the tool is reached via Pattern 2.

  • Error handling: Validate inputs and return structured errors via the output schema rather than raising opaquely.

  • Documentation: Include clear usage examples and runtime requirements.

  • Tests: Tools are pure Python — test them like any other function, no agent needed.

  • Dependencies: Manually maintain requirements.txt with only runtime dependencies.

Tool requirements

  • Inherit from the appropriate base classes:

    • Input/output schemas from BaseIOSchema

    • Configuration from BaseToolConfig

    • Tool class from BaseTool[Input, Output]

  • Include proper documentation and usage examples

  • Include tests for the tool’s pure logic

  • Follow the standard directory structure if shipping via the Atomic Forge

Next steps

  1. Browse available tools in the Atomic Forge directory.

  2. Try Pattern 1 by chaining a query agent into a search tool — the README’s “Chaining Schemas” example is a good starting point.

  3. Try Pattern 2 by running the orchestration-agent example.

  4. Build your own tool and contribute it back via the Atomic Forge.