Module 4 ended with a precisely drawn boundary: DocuBot knows — it answers with sources about everything the Nubelia documentation says — but it cannot do. The question we left hanging, "How many tasks does my project Atlas have right now?", has no answer in any wiki: the data lives in the Nubelia API, changes by the minute, and no RAG pipeline in the world is going to retrieve it from a collection of chunks. In this lesson we cross that boundary with the mechanism called function calling (or tool use): we describe to the model what functions we have, the model decides when and with what arguments it makes sense to call them, and our code executes them and returns the result. It is probably the worst-named idea in the whole ecosystem — the model doesn't call any function — and at the same time one of the most transformative: it is the piece that separates a chatbot from an assistant that operates on your system. Here we will build DocuBot's first tool, query_project, and finally close the Atlas question.

Contents

  1. The key idea: the model decides, your code executes
  2. Defining a tool: JSON Schema and descriptions that are prompt engineering
  3. The complete flow with the SDK: tool_use → execute → tool_result → final answer
  4. DocuBot's first tool: query_project against the Nubelia API
  5. When the tool fails: errors as a tool_result with is_error
  6. tool_choice: automatic, forced and forbidden selection
  7. Function calling as the evolution of "asking for JSON" (02-03)

The key idea: the model decides, your code executes

Let's first dismantle the misunderstanding that gives the technique its name. In function calling, the LLM executes absolutely nothing: it has no access to your network, your database, or the Nubelia API. The only thing the model does is what it has always done — generate tokens — except that now it can generate a special type of structured output that means "I want you to call this function with these arguments". Your application reads that request, decides whether to honor it, executes the function with plain, ordinary Python code, and returns the result to the model in a second turn so it can write the final answer.

sequenceDiagram
    participant U as User
    participant A as Your application (Python)
    participant L as LLM (API)
    participant N as Nubelia API
    U->>A: "How many tasks does my project Atlas have?"
    A->>L: question + tool definitions
    L-->>A: tool_use: query_project({"name": "Atlas"})
    Note over L: The model does NOT execute anything.<br/>It only asks, with structured arguments.
    A->>N: get_project("Atlas") — your code
    N-->>A: {"open_tasks": 27, ...}
    A->>L: tool_result with that JSON
    L-->>A: "Your project Atlas has 27 open tasks..."
    A-->>U: final answer in natural language

This division of labor has important consequences, all of them good:

  • You are in control. Between the tool_use and the execution there is a line of Python code that you write. You can validate the arguments, apply permissions, log the call, or refuse to execute it. The model proposes; your code disposes.
  • Security is reasoned about the same as always. The function you execute is your code running with your application's credentials — the same time-honored principles apply, starting with least privilege: a query tool should use read-only credentials. query_project cannot delete anything because the code that implements it doesn't know how to delete anything. (Write actions and their safeguards arrive in 05-02, and the attacks that exploit tools, in 06-01.)
  • The model contributes what no if ever could: understanding that "how's Atlas doing?", "give me the status of my project Atlas" and "¿cuántas tareas abiertas tiene Atlas?" are the same intent, and translating it into a call with correct arguments.

Defining a tool: JSON Schema and descriptions that are prompt engineering

For the model to be able to request a function, you first have to describe it. Every tool is defined with three pieces:

Field What it is Who "reads" it
name Identifier of the function (letters, numbers, underscores) Your code, to know what to execute
description A natural-language explanation of what it does, when to use it and when not to The model — this is the most important piece
input_schema JSON Schema of the arguments: types, required fields, enums The model (to generate valid arguments) and the API (which validates them)

The description deserves a pause: it is prompt engineering. Everything you learned in module 2 applies here — the model will decide whether to use the tool, and with what arguments, based almost exclusively on that text. Compare:

# Poor description: the model will have to guess
{"description": "Queries a project"}

# Well-crafted description: when to use it, what it returns, what it does NOT do
{"description": (
    "Queries the REAL-TIME status of one of the user's projects in Nubelia: "
    "open and completed tasks, team members and due date. "
    "Use it when the user asks for current data about a specific project "
    "('how many tasks does Atlas have?', 'how's my project Boreal doing?'). "
    "Do NOT use it for questions about how Nubelia works (plans, permissions, "
    "integrations): those are answered from the documentation."
)}

The second version does three things the first doesn't: it delimits the when yes with examples (few-shot in miniature, 02-02), it delimits the when not (it keeps the model from calling the API for documentation questions), and it announces what it returns (the model will write a better final answer knowing what to expect).

The complete flow with the SDK: tool_use → execute → tool_result → final answer

Let's see the mechanism with the anthropic SDK we've been using since 03-01 — the flow is conceptually identical in OpenAI, Google or Mistral (the field names change: tools/tool_calls/function, but the dance is the same). The steps:

  1. First call: you send the question and the list of tools (the tools parameter).
  2. If the model decides to use one, the response arrives with stop_reason == "tool_use" and a content block of type tool_use with name, input (the arguments, already as a dict) and a correlation id.
  3. Your code executes the corresponding function.
  4. Second call: you resend the full conversation — including the assistant's response with its tool_use block — and add a user message with a tool_result block that references that id.
  5. The model, now with the data in hand, writes the final answer (stop_reason == "end_turn").

Steps 2 and 4 hide the two details that cause the most errors: the assistant's tool_use block must go back into the history (otherwise the model doesn't know what it requested), and the tool_result goes inside a message with role user — from the conversation's point of view, the tool's result is something you "tell" the model, and it must be the first content of that message.

DocuBot's first tool: query_project against the Nubelia API

Let's actually build it. Since we don't have a deployed Nubelia API in this course, we simulate it with a mock module — the didactic beauty is that the model couldn't care less: all it sees is the tool definition and the results; whether behind it there's a dict or a microservice with a database is invisible from its side of the boundary.

# nubelia_api.py — simulation of Nubelia's internal API
# In production, these functions would make authenticated HTTP calls
# (with READ-ONLY credentials: least privilege).

class ProjectNotFound(Exception):
    pass

_PROJECTS = {
    "atlas":  {"name": "Atlas",  "status": "active", "open_tasks": 27,
               "completed_tasks": 143, "members": 6, "due_date": "2026-09-30"},
    "boreal": {"name": "Boreal", "status": "active", "open_tasks": 4,
               "completed_tasks": 89,  "members": 3, "due_date": "2026-07-15"},
    "cierzo": {"name": "Cierzo", "status": "paused", "open_tasks": 12,
               "completed_tasks": 51,  "members": 4, "due_date": None},
}

def get_project(name: str) -> dict:
    """Returns the current status of a project. Raises ProjectNotFound if it doesn't exist."""
    key = name.strip().lower()
    if key not in _PROJECTS:
        available = ", ".join(p["name"] for p in _PROJECTS.values())
        raise ProjectNotFound(
            f"No project named '{name}' exists. Available projects: {available}."
        )
    return _PROJECTS[key]

Now the tool definition and its dispatcher, in a new module that will grow throughout this module of the course:

# tools.py — DocuBot's tool definitions and their execution
import nubelia_api

TOOLS = [
    {
        "name": "query_project",
        "description": (
            "Queries the REAL-TIME status of one of the user's projects in Nubelia: "
            "open and completed tasks, members and due date. Use it when the user "
            "asks for current data about a specific project. Do NOT use it for "
            "questions about how Nubelia works (documentation)."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "name": {
                    "type": "string",
                    "description": "Name of the project as the user mentions it, e.g. 'Atlas'.",
                },
            },
            "required": ["name"],
        },
    },
]

def run_tool(name: str, arguments: dict) -> dict:
    """Bridge between what the model requests and the code that does it."""
    if name == "query_project":
        return nubelia_api.get_project(arguments["name"])
    raise ValueError(f"Unknown tool: {name}")

And the complete flow. Notice that we reuse call_with_retries (03-02) and MODEL (03-01) without touching them — tools are just one more parameter of the call. What we do need is a new system prompt: the SYSTEM_DOCUBOT from 02-01 orders the model to rely exclusively on the <documentation> block, and that rule, perfect for RAG, would leave the tool unused. We add a variant to prompts.py (and bump PROMPT_VERSION, as 02-04 demands):

# prompts.py (added in 05-01) — tool-aware version of the system prompt
SYSTEM_DOCUBOT_TOOLS = """You are DocuBot, Nubelia's internal assistant.
You only answer questions about Nubelia. For real-time data about the user's
projects, use the available tools. Never invent project data: if a tool fails
or can't find something, explain the problem clearly.
Answer in the user's language, with a professional, concise tone."""
# docubot_tools.py — the tool_use -> execute -> tool_result flow
import json
from client import MODEL, extract_text
from retries import call_with_retries
from prompts import SYSTEM_DOCUBOT_TOOLS
from tools import TOOLS, run_tool

def answer_with_tool(question: str) -> str:
    messages = [{"role": "user", "content": question}]

    # 1) First call: question + available tools
    response = call_with_retries(
        model=MODEL,
        max_tokens=1024,
        system=SYSTEM_DOCUBOT_TOOLS,
        tools=TOOLS,
        messages=messages,
    )

    # Did the model answer directly, without needing a tool?
    if response.stop_reason != "tool_use":
        return extract_text(response)

    # 2) The model requested a tool: locate the tool_use block
    block = next(b for b in response.content if b.type == "tool_use")
    print(f"[tool_use] {block.name}({block.input})")   # minimal trace, for now

    # 3) EXECUTE — this is our Python, not the model
    try:
        result = run_tool(block.name, block.input)
        content = json.dumps(result, ensure_ascii=False)
        is_error = False
    except Exception as e:
        content = str(e)              # the error, told as text
        is_error = True

    # 4) Second call: full history + tool_result correlated by id
    messages.append({"role": "assistant", "content": response.content})
    messages.append({
        "role": "user",
        "content": [{
            "type": "tool_result",
            "tool_use_id": block.id,
            "content": content,
            "is_error": is_error,
        }],
    })
    final = call_with_retries(
        model=MODEL, max_tokens=1024,
        system=SYSTEM_DOCUBOT_TOOLS,
        tools=TOOLS, messages=messages,
    )
    return extract_text(final)


print(answer_with_tool("How many tasks does my project Atlas have right now?"))

A typical run:

[tool_use] query_project({'name': 'Atlas'})
Your project Atlas currently has 27 open tasks (and 143 completed).
The project is active, with 6 members and a due date of September 30, 2026.

The question that served us in 01-04 as the example of the impossible has just been answered — and with the system's real data, not a plausible hallucination. Three observations before moving on:

  • block.input arrives as a dict, already parsed by the SDK. Even so, treat it as untrusted input: the model can pass a project name that doesn't exist or an unexpected value. Our defense lives in get_project (it normalizes and validates), not in trusting the model.
  • The result travels as text (hence the json.dumps): a tool_result is content for the model, and compact JSON with descriptive keys is a format it understands beautifully. No prose needed.
  • This flow resolves one tool step and finishes. If the model needed to chain several calls (query the documentation and then the API), we would need a loop — exactly the topic of 05-02; let's not get ahead of ourselves.

When the tool fails: errors as a tool_result with is_error

You already saw the try/except in the previous code; now the philosophy. When a tool fails, the worst option is to let the exception blow up the flow, and the second worst is to return a traceback to the user. The good option is the one we implemented: return the error to the model as a tool_result with is_error: True, with an informative error message — and let the model do what it does best: explain it in natural language.

Question: "how's my project Sapphire doing?". The tool raises ProjectNotFound with our message ("No project named 'Sapphire' exists. Available projects: Atlas, Boreal, Cierzo."), and the model answers something like:

I can't find a project named "Sapphire" in your Nubelia account.
Your current projects are Atlas, Boreal and Cierzo — did you mean one of those?

Notice the value chain: the exception's message is also prompt engineering. Because we included the list of available projects in the error, the model can offer alternatives instead of a curt "it doesn't exist". Write your tools' errors knowing that their reader is an LLM that will re-explain them: what failed, why, and what can be done. And the same rule as always: in the error message, no secrets or internal details (paths, SQL, stack traces) — they would go straight into the model's context and, potentially, into the user's answer.

tool_choice: automatic, forced and forbidden selection

By default the model freely decides whether to use a tool or answer directly. The tool_choice parameter hands you the controls when you need them:

Value Behavior When to use it
{"type": "auto"} The model decides (default with tools) Assistants like DocuBot: some questions need a tool and others don't
{"type": "any"} Forced to use some tool Pure routers: a direct answer is not a valid option
{"type": "tool", "name": "..."} Forced to use that tool Structured extraction: you want the arguments, not a conversation
{"type": "none"} Forbidden to use tools Forcing a closing answer with the tools still declared

The third case is more useful than it looks and connects directly with the next section: if you force a specific tool, the model always generates a tool_use block with arguments that satisfy your input_schema. In other words: you just obtained guaranteed structured output.

Function calling as the evolution of "asking for JSON" (02-03)

In 02-03 we built DocuBot's JSON contract by requesting the format in the prompt and defending ourselves with clean_json and validate_contract — the model sometimes wrapped the JSON in prose or code fences, and it had to be cleaned. Function calling is the industrialized version of that idea:

Aspect Asking for JSON in the prompt (02-03) Function calling
How the format is specified Prose + example in the prompt Formal input_schema (JSON Schema)
Syntactic reliability High but not guaranteed (hence clean_json) Very high: the model is specifically trained to emit schema-conforming arguments
"Chatter" around the JSON Frequent without iron-clad instructions Doesn't exist: the tool_use block is pure structure
Types and enums Requested politely Declared in the schema
Semantic validation (values that make sense) Yours (validate_contract) Still yours

The last row is the moral: function calling eliminates the syntactic plumbing, but defensive validation of values doesn't retire — the fact that {"name": "Sapphire"} is perfect JSON doesn't make Sapphire exist. The instinct from 02-03 ("don't trust, validate") is still the law; only where to apply it has changed.

Common Mistakes and Tips

  • Forgetting to resend the assistant's turn. The most frequent error in the flow: sending the tool_result without first appending {"role": "assistant", "content": response.content} to the history. The API will reject the request — the tool_result references a tool_use_id that, without that message, doesn't exist in the conversation.
  • Skimpy tool descriptions. "Queries a project" produces both too many calls (for documentation questions) and too few (for indirect phrasings). Treat the description with the same rigor you gave SYSTEM_DOCUBOT: when yes, when not, examples.
  • Believing the schema validates for you. The input_schema guarantees shape, not truth. Validate the values in your function (does the project exist? is the range reasonable?) as if a stranger had typed them — because, in a way, that's what happened.
  • Not covering the no-tool path. With tool_choice: auto, the model can answer directly (stop_reason == "end_turn"). If your code assumes there will always be a tool_use block, that next(...) will raise StopIteration on the first trivial question. Always check stop_reason first.
  • Several tool_use blocks in the same turn. The model can request more than one tool at once (parallel calls). Today's flow processes one because its only tool doesn't allow for more; the loop in 05-02 will iterate over all the blocks. If it happens to you before then, respond with one tool_result per tool_use_id.
  • Tool errors that help nobody. KeyError: 'sapphire' as a tool_result forces the model to improvise. Write errors with context and alternatives — and without leaking internal information.

Exercises

  1. Second tool: list_projects. Add to tools.py a tool with no arguments that returns the user's list of projects (name and status), and extend run_tool. Test with "what projects do I have right now?" and with "how's Atlas doing?" to check that the model picks the right tool in each case.
  2. The error as conversation. With the lesson's code, ask "how many tasks does my project Sapphire have?" and observe the answer. Then deliberately impoverish the ProjectNotFound message (reduce it to "error") and repeat. Compare both final answers and write the conclusion in one sentence.
  3. Structured extraction with a forced tool. Define a tool extract_query whose input_schema has category (an enum with the 6 categories of classification_prompt() from 02-02) and project (optional string). Call it with tool_choice: {"type": "tool", "name": "extract_query"} on the question "it won't let me bill the hours for project Boreal" and examine block.input. Which piece from 02-03 did you just replace?

Solutions

  1. Definition and dispatch:
# In TOOLS, add:
{
    "name": "list_projects",
    "description": (
        "Lists all of the user's projects in Nubelia with their name and status. "
        "Use it when the user asks what projects they have or wants an overview."
    ),
    "input_schema": {"type": "object", "properties": {}},   # no arguments
}

# In nubelia_api.py:
def list_projects() -> list[dict]:
    return [{"name": p["name"], "status": p["status"]} for p in _PROJECTS.values()]

# In run_tool:
    if name == "list_projects":
        return nubelia_api.list_projects()

With "what projects do I have?" the model should pick list_projects; with "how's Atlas doing?", query_project. If it gets confused, the lever is the description (not the code!).

  1. With the rich error, the model answers with concrete alternatives ("your projects are Atlas, Boreal and Cierzo — did you mean one of those?"). With a bare "error", the model can only say that something failed, or worse, speculate about the reason. Conclusion in one sentence: the quality of the final answer when something fails is bounded by the quality of the error message your tool returns — tool errors are part of the prompt.

  2. Schema and call:

EXTRACT_QUERY_TOOL = {
    "name": "extract_query",
    "description": "Extracts the category and the mentioned project from a user query.",
    "input_schema": {
        "type": "object",
        "properties": {
            "category": {"type": "string",
                         "enum": ["billing", "permissions", "integrations",
                                  "api", "incidents", "other"]},
            "project": {"type": "string",
                        "description": "Name of the project mentioned, if any."},
        },
        "required": ["category"],
    },
}

response = call_with_retries(
    model=MODEL, max_tokens=256,
    tools=[EXTRACT_QUERY_TOOL],
    tool_choice={"type": "tool", "name": "extract_query"},
    messages=[{"role": "user", "content": "it won't let me bill the hours for project Boreal"}],
)
block = next(b for b in response.content if b.type == "tool_use")
print(block.input)   # {'category': 'billing', 'project': 'Boreal'}

You just replaced the "ask for JSON in the prompt + clean_json" tandem: with the forced tool there is no prose to clean and no code fences to strip. validate_contract (the semantic validation) would still have work to do — the enum guarantees that category is one of the six, but nobody guarantees it's the correct one.

Conclusion

DocuBot just did something no RAG could: answer with a piece of data that wasn't written anywhere when the conversation started. The mechanism, once its name is dismantled, is honest and simple — the model decides which function to call and with what arguments, your code executes and returns the result, the model writes — and its pieces are old acquaintances in new clothes: the description is prompt engineering (module 2), the input_schema is the JSON contract of 02-03 with factory-fitted syntactic guarantees, errors as a tool_result with is_error are the graceful degradation of 03-02 narrated to the model, and forced tool_choice gives us, as a bonus, the most robust structured extraction we know. But our answer_with_tool() has a structural limitation that may already be bothering you: it executes one tool step and finishes. If the user asks "is my project Atlas over my plan's task limit?", two lookups are needed — the documentation for the plan's limit, the API for Atlas's tasks — plus a comparison between the two. That is no longer a two-call flow: it's a loop in which the model reasons, acts, observes the result and decides whether it needs to continue. That loop has a name — agent — and it is exactly what we build in the next lesson, where the module 4 RAG will also undergo an elegant transformation: from pipeline to tool.

© Copyright 2026. All rights reserved