Tools: Giving Quill New Powers (smolagents, Module 3)

Module 3 of 15 13 min read Lab code ↗

This is Module 3 of smolagents Mastery, a free 15-module course that takes you from your first code agent to a production-grade one. Start at Module 1 or browse the full syllabus.

In Module 2, Quill could answer a question about a CSV. It worked, but watch what it actually did. Every single run, the model had to re-discover the path to data/sales.csv, re-write the pd.read_csv call, re-guess the column names, re-infer the dtypes, and hand-roll a matplotlib chart. Ask the same question twice and it writes the same boilerplate twice — and it gets it wrong about one time in three. Wrong separator. A column called category typed as Catgory. A plt.show() that opens a window and saves nothing. The code is disposable; it is never reused; the agent is condemned to relearn its own dataset on every loop.

There is a better primitive. Instead of making the model reinvent read_csv and savefig from scratch, you give it tools — reliable, validated, documented Python functions it can simply call. By the end of this module you will know how to give a CodeAgent durable, reusable capabilities two different ways, and you will understand exactly how smolagents turns a plain Python function into something the model knows how to invoke. Quill stops rewriting everything and gets a toolbox.

In this module

You’ll learn:

  • Build a tool two ways — the @tool decorator (fast) and a Tool subclass (with setup() for expensive init) — and choose the right one.
  • Explain how smolagents validates and exposes a tool: name/description/inputs/output_type, AUTHORIZED_TYPES, validate_arguments, and the get_json_schema behind @tool.
  • Distinguish how the same tool is presented to a CodeAgent (a callable Python function) versus a ToolCallingAgent (a native JSON schema).
  • Use the default tools (WebSearchTool, VisitWebpageTool, add_base_tools, TOOL_MAPPING) and manage the toolbox at runtime via agent.tools.
  • Apply the habits of a good tool — precise docstring, useful print(), informative ValueError — so the agent corrects itself.

You’ll build: Quill’s first reusable toolbox — load_dataset and profile_dataframe via @tool, a save_chart Tool subclass that lazily boots matplotlib in setup(), plus WebSearchTool() and VisitWebpageTool() wired into build_quill.

Theory areas covered: T3 — Tools — 10% of the theory map.

Prerequisites: Module 2 (CodeAgent, the ReAct loop, run(), print()Observation, final_answer). You have smolagents[toolkit]==1.26.0 installed, HF_TOKEN in .env, plus pandas and matplotlib. (Reminder: matplotlib is not a smolagents dependency — you add it yourself.)

Where you are in the course

  • Modules 1–2 — the series, Quill, and your first CodeAgent running the ReAct loop.
  • 👉 Module 3 — Tools (you are here).
  • Modules 4–15 — models and cost, sandboxing, memory, planning, reliability, MCP and the Hub, multi-agent teams, vision, RAG, deployment, telemetry, and the capstone.

Quill before this module: ad-hoc pandas re-written in every run, guessing the schema each time. Quill after this module: a frozen, reusable data toolbox (load_dataset, profile_dataframe, save_chart) plus web access — capabilities it calls instead of reinventing.

What a tool actually is — and the two ways to build one

A tool is a function you expose to the agent. smolagents describes every tool with four attributes plus its logic:

  • name — a short, performative identifier (must be a valid Python identifier, not a reserved keyword).
  • description — what the tool does, its inputs, and its output, in plain language.
  • inputs — a dict mapping each argument name to a {"type": ..., "description": ...} sub-dict.
  • output_type — what the tool returns, as one type string.
  • forward — the method that holds the actual logic.

Here is the part beginners skip, and it is the most important sentence in this module: the description and inputs are injected into the agent’s system prompt. They are literally the documentation the model reads to decide whether — and how — to call the tool. A tool with a vague description is a tool the model calls wrong, or never calls at all. The doc is not cosmetic. It is the interface.

flowchart LR
    A["Your Python function<br/>or Tool subclass"] -->|"@tool / Tool"| B["name · description<br/>inputs · output_type<br/>forward()"]
    B -->|injected at agent init| C["The agent's<br/>system prompt"]
    C --> D["The model reads it<br/>and decides to call"]
    D -->|"CodeAgent: result = my_tool(...)"| E["forward() runs"]
    D -->|"ToolCallingAgent: {name, arguments}"| E
    E -->|return value| F["Observation:<br/>next step"]

AUTHORIZED_TYPES and nullable inputs

Both inputs[*]["type"] and output_type must be drawn from a fixed list. As of smolagents 1.26.0, AUTHORIZED_TYPES is exactly:

AUTHORIZED_TYPES = [
    "string", "boolean", "integer", "number",
    "image", "audio", "array", "object", "any", "null",
]

These are JSON-schema-style names, not Python type names — note "string", not "str", and "number" for floats. An output_type (or input type) outside this list fails at instantiation, not at run time. An input’s type can also be a list of these strings when the argument legitimately accepts more than one type. For an optional input, add "nullable": True to its sub-dict — that is exactly how the built-in GoogleSearchTool declares its filter_year argument, and how Quill’s save_chart declares its optional filename.

Validation happens automatically, at construction

You do not call a validator yourself. When you subclass Tool, smolagents’ Tool.__init_subclass__ wraps your __init__ so that validate_arguments() runs the moment you instantiate the tool. (The base __init__ does almost nothing — it just sets self.is_initialized = False.) Validation checks:

  • name is a string, a valid Python identifier, and not a reserved keyword.
  • description and inputs are present and correctly typed; each input carries "type" (drawn from AUTHORIZED_TYPES) and "description".
  • output_type is a string in AUTHORIZED_TYPES.
  • Signature consistency — the parameters of forward must match the keys of inputs exactly, and the type hints must line up with the declared input types.

This is a safety net with real value: a mismatch between your forward signature and your inputs dict blows up at startup, not fifteen steps into a live run while you are paying for tokens. The lab’s smoke test leans on this directly — it instantiates each tool and calls validate_arguments() to prove the schema is well-formed offline.

Way 1 — the @tool decorator

For a simple, single-purpose tool, the @tool decorator is the recommended path. You write a normal function; smolagents derives the whole schema for you. Here is Quill’s first real tool, load_dataset (the frozen contract is load_dataset(path: str) -> str):

from smolagents import tool

@tool
def load_dataset(path: str) -> str:
    """Load a tabular dataset (CSV or Parquet) and return a short text summary.

    Use this first to discover a dataset's shape and column names before writing any
    analysis code, so you never have to guess the schema. Prints a one-line summary and
    returns the same summary as the observation.

    Args:
        path: Filesystem path to the dataset (a ``.csv`` or ``.parquet`` file).
    """
    import pandas as pd  # import INSIDE the function (a "pushable" rule — explained below)
    df = pd.read_csv(path)
    summary = (
        f"Loaded {path}: {df.shape[0]} rows x {df.shape[1]} columns. "
        f"Columns: {list(df.columns)}."
    )
    print(summary)        # print() -> the next step's Observation
    return summary        # the return value also comes back as Observation

Under the hood, @tool calls get_json_schema(tool_function) (a Hugging Face / transformers chat-template utility) to read your function, then builds a dynamically-created Tool subclass — internally called SimpleTool — whose forward is your function. The mapping is exact:

Source on the functionBecomes
The function namethe tool name (load_dataset)
The first docstring paragraphthe description
The Args: section of the docstringeach input’s description
The parameter type hintsthe input types (path: str"string")
The return type hintthe output_type (-> str"string")

Because the schema is derived from the function, the requirements are non-negotiable: a type hint on every parameter and on the return value, plus a docstring with an Args: section. Drop the return hint and get_json_schema cannot derive output_type; drop the Args: section and your inputs have no descriptions for the model to read. This is the single most common beginner mistake — the function runs fine as plain Python but the tool is never properly exposed to the model.

You can verify the derived schema for yourself:

print(load_dataset.name)         # "load_dataset"
print(load_dataset.inputs)       # {'path': {'type': 'string', 'description': 'Filesystem path ...'}}
print(load_dataset.output_type)  # "string"
load_dataset
{'path': {'type': 'string', 'description': 'Filesystem path to the dataset (a ``.csv`` or ``.parquet`` file).'}}
string

Way 2 — subclassing Tool

When you need more than a single function — multiple methods, internal state, or an expensive one-time init — you subclass Tool directly. Now you declare name/description/inputs/output_type as class attributes (not constructor arguments) and write forward yourself. Quill’s save_chart is exactly this case, because it needs to configure matplotlib once before it can ever run. We will look at its full implementation in the next section, but the shape is:

from smolagents import Tool

class save_chart(Tool):           # the canonical tool name is exactly "save_chart"
    name = "save_chart"
    description = "Save the CURRENT matplotlib figure to outputs/ as a PNG and return its path. ..."
    inputs = {
        "filename": {
            "type": "string",
            "description": "Optional base filename. '.png' is added if missing.",
            "nullable": True,    # optional input
        }
    }
    output_type = "string"

    def forward(self, filename: str | None = None) -> str:
        ...

@tool is sugar over exactly this. The decorator builds the same kind of subclass; you would only hand-write the class when you need the extra structure — which brings us to setup().

setup(), lazy init, and the lifecycle of a tool

Some tools are expensive to prepare. Loading a model, opening a database connection, or — in Quill’s case — importing and configuring matplotlib is work you want to do once, and only if the tool is actually used. smolagents gives you exactly one place for that: setup.

Here is the mechanism. A tool’s __call__ is roughly __call__(self, *args, sanitize_inputs_outputs=False, **kwargs). On invocation, if not self.is_initialized it runs self.setup(), then calls self.forward(...). So setup() runs lazily, on the first call only — never at instantiation. Construct ten tools and you load zero models; you pay each tool’s setup cost only when the agent reaches for it.

This is why save_chart is a subclass and not a @tool function. It selects matplotlib’s non-interactive Agg backend — the one that writes PNGs to disk with no display — and that belongs in setup():

class save_chart(Tool):
    # ... name / description / inputs / output_type as above ...

    def setup(self) -> None:
        """Lazy, one-time init: force the non-interactive 'Agg' backend."""
        import matplotlib
        matplotlib.use("Agg")     # non-interactive: write PNGs, never open a window
        super().setup()           # flips is_initialized so this never runs twice

    def forward(self, filename: str | None = None) -> str:
        import datetime, os
        import matplotlib.pyplot as plt

        os.makedirs("outputs", exist_ok=True)
        if not filename:
            filename = f"chart-{datetime.datetime.now():%Y%m%d-%H%M%S-%f}"
        if not filename.endswith(".png"):
            filename = f"{filename}.png"

        out_path = os.path.join("outputs", filename)
        fig = plt.gcf()
        if not fig.get_axes():
            raise ValueError(
                "No figure to save — draw a chart with matplotlib (e.g. df.plot(...) or "
                "plt.plot(...)) BEFORE calling save_chart."
            )
        fig.savefig(out_path, bbox_inches="tight")
        plt.close(fig)
        print(f"Saved chart to {out_path}")
        return out_path

The key line is super().setup(): the base implementation flips is_initialized to True, so the backend selection runs exactly once across the agent’s whole run. The lab proves the lazy behavior directly — a freshly constructed save_chart() reports is_initialized is False, and only after the first call does it become True.

You will also see sanitize_inputs_outputs=True referenced in the __call__ signature. When set, smolagents coerces a tool’s inputs and outputs through the Agent Types so non-text values (images, audio) round-trip cleanly. We cover Agent Types at the end of this module.

Here is the tool lifecycle at a glance:

PhaseWhen it runsWhat goes hereCost
__init__At construction (save_chart())Almost nothing — base sets is_initialized = False; validate_arguments() firesCheap; do not load anything heavy
setup()Lazily, on the first call onlyExpensive one-time init (load a model, pick a backend, open a connection)Paid once, and only if the tool is used
__call__forward()Every callThe actual workPer call

Managing the toolbox at runtime

An agent’s toolbox is not frozen at construction. agent.tools is a plain Python dict, keyed by each tool’s name. You can add or replace a tool live, without rebuilding the agent:

from smolagents import VisitWebpageTool

agent = build_quill()
print(sorted(agent.tools))
# ['final_answer', 'load_dataset', 'profile_dataframe', 'save_chart', 'visit_webpage', 'web_search']

extra = VisitWebpageTool()
agent.tools[extra.name] = extra   # add or replace by name

The dict is keyed by name, which has one consequence worth burning into memory: two tools with the same name overwrite each other. That is harmless when you mean it (swapping one tool for another) and a silent bug when you do not — which is exactly the trap waiting in the next section, where four different built-in tools all answer to name="web_search".

Default tools, add_base_tools, and TOOL_MAPPING

smolagents ships a roster of ready-made tools. As of smolagents 1.26.0 these live in default_tools.py:

ToolnameWhat it doesNeeds
PythonInterpreterToolpython_interpreterRuns Python in the restricted local executorcore
FinalAnswerToolfinal_answerReturns the answer and ends the runcore (always present)
UserInputTooluser_inputPrompts the user via stdincore
WebSearchToolweb_searchWeb search with selectable engine (recommended)ddgs from [toolkit]
DuckDuckGoSearchToolweb_searchDuckDuckGo searchddgs from [toolkit]
GoogleSearchToolweb_searchGoogle via SerpAPI / SerperSERPAPI_API_KEY / SERPER_API_KEY
ApiWebSearchToolweb_searchGeneric API search (defaults to Brave)BRAVE_API_KEY
VisitWebpageToolvisit_webpageFetch a URL, HTML → markdownmarkdownify from [toolkit]
WikipediaSearchToolwikipedia_searchWikipedia lookupwikipedia-api
SpeechToTextTooltranscriberWhisper speech-to-text[transformers] + torch

Read the name column carefully. Four tools share name="web_search"WebSearchTool, DuckDuckGoSearchTool, GoogleSearchTool, and ApiWebSearchTool. That is deliberate: they are drop-in interchangeable, so you can swap a search backend without changing anything else. But because agent.tools is a dict keyed by name, you put exactly one of them in an agent at a time. Add two and the second silently clobbers the first. (As of smolagents 1.26.0, the docs recommend WebSearchTool as the default search tool.)

WebSearchTool and VisitWebpageTool

Quill wires in two of these for web context. As of smolagents 1.26.0, the constructor is WebSearchTool(max_results: int = 10, engine: str = "duckduckgo"). Available engines are "duckduckgo" (default), "bing", and "exa" — and Exa was added in 1.26.0 (it requires EXA_API_KEY). The engine list may grow in later versions, so treat it as version-dependent.

One freshness trap matters here. The default DuckDuckGo engine needs the ddgs package (≥9.0.0, shipped by the [toolkit] extra) — not duckduckgo-search, which is the old name; the package was renamed upstream. Any tutorial telling you to pip install duckduckgo-search is stale.

VisitWebpageTool(max_output_length: int = 40000) (name="visit_webpage") fetches a URL, converts the HTML to markdown, and truncates it. It needs markdownify, also from [toolkit].

add_base_tools and TOOL_MAPPING

You can ask an agent to include the base tools by name with add_base_tools=True. Here is what actually happens — and it is not what most tutorials say. As of smolagents 1.26.0, TOOL_MAPPING contains exactly three classes:

TOOL_MAPPING = {
    tool_class.name: tool_class
    for tool_class in [
        PythonInterpreterTool,
        DuckDuckGoSearchTool,
        VisitWebpageTool,
    ]
}

When add_base_tools=True, smolagents’ _setup_tools adds those three — with two crucial caveats visible right in the source:

if add_base_tools:
    self.tools.update(
        {
            name: cls()
            for name, cls in TOOL_MAPPING.items()
            if name != "python_interpreter" or self.__class__.__name__ == "ToolCallingAgent"
        }
    )

First, python_interpreter is excluded for a CodeAgent (a CodeAgent already executes Python natively — giving it a Python interpreter tool would be redundant) but included for a ToolCallingAgent. Second, FinalAnswerTool is always added to every agent regardless of add_base_tools — it is the termination mechanism you met in Module 2, not an optional extra.

⚠️ Common misconception

add_base_tools=True gives a CodeAgent a Python interpreter tool, and the default toolbox includes a Transcriber.”

Both halves are wrong. For a CodeAgent, python_interpreter is explicitly excluded — the agent is the interpreter; it is only added for a ToolCallingAgent. And the default toolbox has no Transcriber: the guided-tour prose still lists “DuckDuckGo, Python interpreter, and Transcriber,” but the actual TOOL_MAPPING (verified against the source as of smolagents 1.26.0) is PythonInterpreterTool + DuckDuckGoSearchTool + VisitWebpageTool. When docs prose and code disagree, the code wins.

This is precisely why Quill does not pass add_base_tools=True. FinalAnswerTool is already there, and for a CodeAgent the python_interpreter tool would be skipped anyway — so it would add nothing. Quill’s toolbox is the three data tools plus exactly one web-search tool and one visit-webpage tool, declared explicitly.

Same tool, two surfaces — and Agent Types

Here is the conceptual core of the module. The same Tool object is presented to the model in two completely different ways depending on the agent class.

To a CodeAgent, a tool is a callable Python function bound into the sandbox. The model writes ordinary Python that calls it directly, and it can compose freely — loop over results, branch on them, chain several tool calls in one step:

summary = profile_dataframe("data/sales.csv")
print(summary)
df = pd.read_csv("data/sales.csv")
totals = df.groupby("category")["net_rev"].sum()
totals.plot(kind="bar")
path = save_chart("category_revenue")
final_answer(f"Team grew fastest. Chart saved at {path}")

To a ToolCallingAgent, the very same tool is presented as a JSON schema (name, description, parameter types) through the model’s native tool-calling API. The agent passes tools_to_call_from=self.tools_and_managed_agents to the model, and the model emits one structured call per turn:

{"name": "profile_dataframe", "arguments": {"path": "data/sales.csv"}}

In both cases, name/description/inputs/output_type are baked into the system prompt at init — the tool’s documentation reaches the model the same way. What differs is the action format, and that difference has a real practical consequence: code-as-action lets a CodeAgent chain and compose many tool calls in a single step (with loops, conditionals, and intermediate variables), where JSON tool-calling typically makes one call per turn. That composability is one of the central advantages reported in the CodeAct paper.

CodeAgent (code-as-action)ToolCallingAgent (JSON tool calls)
Exposed to the model asCallable Python functions in the sandboxJSON tool schemas via native tool-calling
Action formatA Python code snippet{"name": ..., "arguments": {...}}
Composability (chain / loop / branch)High — multiple calls in one stepLow — typically one call per turn
Parallel callsAuthor them in codeBuilt-in via max_tool_threads (ThreadPoolExecutor)
DebuggabilityRead the actual Python it ranRead structured call/argument records

The ToolCallingAgent class

Now that the JSON surface is clear, here is the other agent class explicitly. As of smolagents 1.26.0:

ToolCallingAgent(
    tools,
    model,
    planning_interval=None,
    stream_outputs=False,
    max_tool_threads=None,
    # ... plus max_steps, verbosity_level, managed_agents, etc. via **kwargs -> MultiStepAgent
)

It emits JSON tool calls validated against each tool’s schema. Internally, the model is given tools_to_call_from, the calls are handled by process_tool_calls, and each one runs through execute_tool_call. The max_tool_threads argument enables parallel tool calls via a ThreadPoolExecutor — handy when a turn issues several independent calls. You will meet ToolCallingAgent again in Module 10, where it becomes Quill’s web_researcher sub-agent.

Choosing between them — the architecture decision of the module

Pick CodeAgent when…Pick ToolCallingAgent when…
The work is reasoning, chaining, and dynamic compositionThe work is dispatching atomic, predefined actions
You want a “problem solver / programmer”You want a “dispatcher / controller”
You benefit from loops, conditionals, and intermediate stateYou need maximum reliability and predictability
You can run code in a sandbox (Module 5)You want clean API interop and parallel tool calls

The CodeAct paper reports that expressing actions as code can cut the number of steps an agent needs by roughly 30% compared to JSON tool-calling on the tasks it studied (as of the framing smolagents adopts in smolagents 1.26.0) — because one code block can do the work of several JSON round-trips. The cost is that a CodeAgent executes model-generated code, which is a real security concern (the whole of Module 5). A ToolCallingAgent trades expressivity for reliability and easy API interop.

This is the design decision behind Quill. Quill is a CodeAgent because its job is reasoning over data composed in code — group, pivot, compute growth, plot, save — not dispatching a fixed menu of atomic actions. Code-as-action is the natural fit, so we accept the executor and lock it down later.

Agent Types

One more piece closes the loop on “what a tool returns.” When a tool returns something other than text, smolagents wraps it in an Agent Type so it serializes and renders cleanly. As of smolagents 1.26.0:

  • AgentText — behaves like a str.
  • AgentImage — behaves like a PIL.Image.Image; .to_raw() returns the PIL image, .to_string() returns a path to the serialized image.
  • AgentAudio.to_raw() returns a torch.Tensor, .to_string() returns a path to the serialized audio.

They share one interface: to_raw() (the underlying object) and to_string() (a string or file path). The coercion happens via handle_agent_input_types / handle_agent_output_types when sanitize_inputs_outputs=True, so a tool that returns an image can round-trip through the agent loop, get rendered in a notebook, and be handed back without losing fidelity. Quill’s tools all return text, so they wrap to AgentText; the real multimodal story — actually passing images to the agent — arrives in Module 11.

Build it: Quill’s first toolbox

Time to assemble everything into Quill. The goal: turn Quill from “re-write pandas every run” into an agent with a real, frozen toolbox — load_dataset and profile_dataframe (via @tool), save_chart (a Tool subclass with a lazy setup()), plus WebSearchTool() and VisitWebpageTool() — all wired into build_quill. At the end, running Quill on the sample CSV profiles the data, writes its own pandas and matplotlib, calls save_chart to write a PNG, and returns a final_answer that includes the saved chart path.

The full, tested code lives at smolagents-course-labs/module-03; the offline smoke tests pass with no network and no token.

Setup. This module’s lab is a cumulative snapshot of the Module 2 code, so first sync the pins. matplotlib is not a smolagents dependency — add it explicitly:

uv pip install "smolagents[toolkit]==1.26.0" "huggingface_hub>=1.0,<2" "pandas>=2.2.3" matplotlib
cp module-03/.env.example module-03/.env   # then add your HF token; never commit .env

[toolkit] provides ddgs>=9.0.0 (for WebSearchTool) and markdownify>=0.14.1 (for VisitWebpageTool).

The data tools. load_dataset and profile_dataframe are the two @tool functions you saw above; their frozen signatures are load_dataset(path: str) -> str and profile_dataframe(path: str) -> str. Both share a small _read_table helper that raises an informative ValueError instead of crashing, so the agent can read the message and self-correct:

def _read_table(path: str):
    import os
    import pandas as pd
    if not os.path.exists(path):
        raise ValueError(
            f"No file at {path!r}. Check the path (it is relative to the working "
            "directory) and try again."
        )
    lower = path.lower()
    if lower.endswith(".csv"):
        return pd.read_csv(path)
    if lower.endswith(".parquet") or lower.endswith(".pq"):
        return pd.read_parquet(path)
    raise ValueError(
        f"Unsupported format for {path!r}. Supported: .csv, .parquet. "
        "Convert the file or point me at a supported one."
    )

Two engineering rules are baked in on purpose. First, every import lives inside the function or method, and save_chart.__init__ takes no argument other than self — these are the “pushable” rules. We are not pushing to the Hub yet (that is Module 9); we just write the tools so Module 9 can push them with no rewrite. Second, each tool has a precise docstring, print()s a useful one-liner, and raises informative ValueErrors. The full theory of good tools lands in Module 7 — here we just practice the habits.

Wire it into build_quill. The @tool functions are already tool objects; the Tool subclass must be instantiated (save_chart()):

from smolagents import CodeAgent, InferenceClientModel, Model, VisitWebpageTool, WebSearchTool
from .tools import load_dataset, profile_dataframe, save_chart

DEFAULT_MODEL_ID = "Qwen/Qwen2.5-Coder-32B-Instruct"
QUILL_IMPORTS = ["pandas", "numpy", "matplotlib.*"]   # superset toward M5; never "*"

def build_quill(model: Model | None = None) -> CodeAgent:
    return CodeAgent(
        tools=[
            load_dataset,
            profile_dataframe,
            save_chart(),            # Tool subclass: instantiate it (setup() runs lazily later)
            WebSearchTool(),         # name="web_search"; use exactly ONE web-search tool
            VisitWebpageTool(),      # name="visit_webpage"
        ],
        model=model or InferenceClientModel(
            model_id=DEFAULT_MODEL_ID, token=os.environ.get("HF_TOKEN")
        ),
        additional_authorized_imports=QUILL_IMPORTS,
        max_steps=8,
    )

A few deliberate choices. We add "matplotlib.*" to additional_authorized_imports so the agent can draw a figure before handing it to save_chart — a clean addition toward Module 5’s frozen list (["pandas","numpy","matplotlib.*","json","statistics"]), not a rename, and never the "*" wildcard. We pin an explicit coder model_id rather than relying on InferenceClientModel’s default (the default is documented as subject to change as of smolagents 1.26.0). We cap max_steps=8 — the library default is 20, and a single-CSV job needs far fewer. And we do not pass add_base_tools=True, for the reasons covered above.

Reminder: this is Module 3, so the model_id is still passed directly. Module 4 turns this into a make_model() helper in quill/config.py — that file does not exist yet.

Runtime toolbox. agent.tools is a dict keyed by name; you can inspect or mutate it live:

agent = build_quill()
print(sorted(agent.tools))
# ['final_answer', 'load_dataset', 'profile_dataframe', 'save_chart', 'visit_webpage', 'web_search']

Run it. Quill reads the trajectory the supported way — agent.replay() and agent.memory.steps (never agent.logs, which was removed in 1.21.0):

uv run python -m quill.agent data/sales.csv "Which product category grew fastest, and back it up with a saved chart?"
─ Step 1 ─
Thought: Profile the dataset first, then compute growth and plot it.
<code>
summary = profile_dataframe("data/sales.csv")
print(summary)
</code>
Observation: Profile of data/sales.csv  Shape: 108 rows x 6 columns ...

─ Step 2 ─
<code>
import pandas as pd
df = pd.read_csv("data/sales.csv")
q = df.assign(quarter=pd.to_datetime(df["month"]).dt.quarter)
g = q.groupby(["category", "quarter"])["net_rev"].sum().unstack()
((g[4] - g[1]) / g[1]).plot(kind="bar")
path = save_chart("category_growth")
final_answer(f"Team grew fastest. Chart saved at {path}")
</code>
Observation: Final answer: Team grew fastest. Chart saved at outputs/category_growth.png

A PNG now sits under outputs/. The sample dataset (data/sales.csv) has 108 rows and the columns month, region_code, category, units, net_rev, churn_flag, with categories Free, Pro, and Team.

Tests. Run the offline suite from the repo root:

uv run pytest module-03/tests/                       # offline (no network, no token)
QUILL_LIVE_TESTS=1 uv run pytest module-03/tests/    # + 1 real run
....................... s                                          [100%]
23 passed, 1 skipped

The offline tests instantiate all three tools and assert validate_arguments() passes, check the frozen load_dataset/profile_dataframe/save_chart schemas, call profile_dataframe("data/sales.csv") directly for a non-empty profile, drive save_chart on a real figure to confirm a PNG is written, and prove setup() is lazy (is_initialized is False until the first call). The single live test (one real run that produces a PNG) is marked @pytest.mark.live, skipped unless QUILL_LIVE_TESTS=1, and skips cleanly without HF_TOKEN. Live budget: ~1 LLM run.

Try it yourself.

  1. Add a fourth @tool, top_n_by(path: str, column: str, n: int) -> str, wire it into build_quill, and watch Quill use it. (Remember: a hint on every parameter and the return, plus an Args: section, or the schema will not derive.)
  2. Swap WebSearchTool() for WebSearchTool(engine="bing") and note that the name stays web_search — which is exactly why you keep only one web-search tool in the agent at a time.

This lab does not introduce make_model() or a provider swap (Module 4), a Docker/E2B sandbox (Module 5), MCP or push_to_hub/load_tool/ToolCollection (Module 9 — the tools are written in pushable style but not pushed), multi-agent (Module 10), real image/vision input (Module 11), or QuillReport/final_answer_checks (Module 8). Verified against smolagents 1.26.0.

In production

A tool is a dependency, and dependencies fail. A save_chart that crashes in production — disk full, missing matplotlib backend, a read-only filesystem — takes the whole run down with it. That is why every Quill tool fails cleanly and informs the agent: a ValueError with a readable message (“No file at …”, “draw a chart before calling save_chart”) gives the model something to correct, instead of a stack trace that ends the run. No silent try/except — the error is either handled explicitly or it propagates.

A bad tool is also a cost. A tool whose description is vague gets called wrong, or not at all — the model burns steps and tokens flailing at an interface it cannot read. Tool documentation is not polish; it is cost engineering, because the description is the only thing the model sees before deciding.

Finally, mind the difference between print() and the return value. As you saw in Module 2, both a tool’s print() output and its return value come back as the next step’s Observation. A tool that prints a one-line summary — shape, columns, missing-value count — hands the model exactly what it needs to plan its next move. Opinionated take: I make every data tool print() a one-line summary; it pays for itself in fewer wasted steps.

In Module 9 you’ll push save_chart to the Hub and pull tools from MCP servers, so your toolbox stops being local.

Concept check

Why this matters

An agent is only as good as its tools. A CodeAgent’s reliability is gated almost entirely on this layer: a well-described, well-validated tool is one the model calls correctly the first time; a vague or broken one is wasted steps, burned tokens, and runs that quietly do the wrong thing. Mastering tools — how to build them, how smolagents exposes them, and how the same tool looks different to a CodeAgent versus a ToolCallingAgent — is the single highest-leverage skill in this course. Everything later (sandboxing, multi-agent, MCP) is built on top of it.

1. You are writing a tool that must load a 4 GB embedding model into memory, but you instantiate many tools at agent startup and most runs never touch this one. Which design avoids paying the load cost up front?

  • A. A @tool function that loads the model at the top of the function body.
  • B. A Tool subclass that loads the model in __init__.
  • C. A Tool subclass that loads the model in setup().
  • D. A @tool function with the model loaded at module import time.

2. A teammate’s @tool function runs perfectly when called directly in Python, but the agent never calls it — it keeps writing its own code instead. The function has type hints on its parameters and the return, but no Args: section in its docstring. What is the most likely cause?

  • A. The function needs to be a Tool subclass to be callable.
  • B. Without the Args: section, the derived schema has no input descriptions, so the model has nothing to read about how to use it.
  • C. @tool requires the function name to match the file name.
  • D. The tool must be registered with add_base_tools=True.

3. Your task needs the agent to call three tools in sequence, filter the combined result, and only then answer — ideally in one step. Which agent surface expresses this most naturally?

  • A. ToolCallingAgent, because JSON tool calls are validated against a schema.
  • B. CodeAgent, because tools are callable Python functions it can chain and filter in one code block.
  • C. Either, identically — the surface makes no difference.
  • D. ToolCallingAgent with max_tool_threads set high.

4. You build a CodeAgent with add_base_tools=True. Which statement is correct as of smolagents 1.26.0?

  • A. It gets python_interpreter, web_search, visit_webpage, and a transcriber.
  • B. It gets python_interpreter and web_search, but final_answer only if you also request it.
  • C. It does not get python_interpreter (excluded for a CodeAgent); it gets web_search and visit_webpage; final_answer is present regardless.
  • D. add_base_tools has no effect on a CodeAgent.

5. You add both WebSearchTool() and DuckDuckGoSearchTool() to the same agent. What happens, and what is the right practice?

  • A. Both coexist; the agent picks whichever is faster per query.
  • B. Instantiation fails with a name-collision error.
  • C. They share name="web_search", so in the agent.tools dict the second overwrites the first — keep exactly one web-search tool per agent.
  • D. The agent merges their results automatically.

Answers.

1 — C. setup() runs lazily, on the first call only, and only if the tool is actually used — so you never pay the 4 GB load for runs that do not touch it. Loading in __init__ (B) pays at construction; loading at the top of a @tool body (A) or at import (D) pays on every call or at import time. Expensive one-time init belongs in setup(), which is why it exists.

2 — B. @tool derives the schema from the function via get_json_schema, and the Args: section is the source of each input’s description — the documentation the model reads to learn how to call the tool. Hints alone give types but no per-input descriptions. The function being callable in Python is irrelevant; what the model sees is the derived schema. (A, C, D are not how the decorator works.)

3 — B. A CodeAgent exposes tools as callable Python functions, so the model can call all three, filter, and answer inside a single code block — the composability advantage of code-as-action. A ToolCallingAgent (A, D) typically emits one JSON call per turn; max_tool_threads parallelizes independent calls but does not give you in-step filtering and chaining. The surface absolutely makes a difference (C is wrong).

4 — C. For a CodeAgent, python_interpreter is explicitly excluded (the agent already runs Python); TOOL_MAPPING’s other two — web_search (DuckDuckGo) and visit_webpage — are added. FinalAnswerTool is always present regardless of add_base_tools. There is no transcriber in the default toolbox (A) despite what the docs prose says.

5 — C. All the web-search tools share name="web_search", and agent.tools is a dict keyed by name, so the second registration silently overwrites the first — no error (B), no merging (A, D). The fix is to use exactly one web-search tool per agent; that shared name exists precisely so they are drop-in interchangeable.

Common pitfalls

  • ddgs, not duckduckgo-search. The package was renamed upstream; [toolkit] installs ddgs>=9.0.0. Any tutorial that says pip install duckduckgo-search is stale and will fail.
  • The default toolbox has no Transcriber. The guided-tour prose still lists one, but TOOL_MAPPING (the code) has exactly three classes as of smolagents 1.26.0. When prose and code disagree, the code wins.
  • @tool needs hints and an Args: docstring. Forget either and get_json_schema cannot derive a complete schema, so the tool is poorly (or never) exposed to the model. This is the number-one beginner mistake.

Key takeaways

  • A tool is a function exposed to the agent via four attributes — name, description, inputs, output_type — plus forward. The description and inputs are injected into the system prompt: the doc is the interface.
  • Build a tool two ways: @tool for simple functions (it derives the schema from hints + an Args: docstring), and a Tool subclass when you need state or an expensive one-time setup().
  • setup() runs lazily on the first call only, never at instantiation — so constructing ten tools loads zero models.
  • The same tool is a callable Python function for a CodeAgent and a JSON schema for a ToolCallingAgent; code-as-action lets a CodeAgent chain and compose calls in one step.
  • add_base_tools=True does not give a CodeAgent a python_interpreter tool (it is excluded for CodeAgent), and FinalAnswerTool is always present regardless.
  • Four built-in tools share name="web_search" — use exactly one at a time, because agent.tools is a dict keyed by name.
  • A good tool print()s a useful summary and raises informative ValueErrors, so the agent can reason and self-correct instead of crashing.

Keep going

Want the full smolagents concept-check bank and the next module in your inbox? Subscribe here — it’s free, like everything in this series.

Next — Module 4: Models and Providers. Right now Quill is hardwired to one model. Module 4 introduces make_model() — one place to swap between Hugging Face Inference, LiteLLM, and a local model, and to see what each run costs.

Links: Lab code (module-03) · Module 2 — Your First CodeAgent · Module 4 — Models and Providers · Course index

References