Skip to content
New

DeepSeek V4.1 Flash free for 7 days: 10M tokens a day after any top-up. Until Nov 30.

How it works

AI Agents and Tool Use: How a Model Calls Functions

How tool use works: a model returns a structured call, your host program runs the tool, and approval rules decide what is allowed.

Beginner11 min readBy NotHoly

A chat model produces text. An agent is a system that lets the model do more than that: it reads a request, decides it needs information or an action from outside the conversation, asks a host program to do the work, and continues with the result. Almost every agent is built on one mechanism, tool use, also called function calling.

This article explains that mechanism step by step, using the request and response shapes from public API documentation. It shows why the host program, and not the model, decides what actually runs, why actions with side effects need a human decision, and what the OWASP list calls excessive agency.

No lab is needed. A short local sketch near the end runs the loop with a stub in place of a model, so you can see the control flow without sending anything to an API.

What an agent is made of

An agent has three parts.

The model takes text in and produces text out. In tool use, it can also produce a structured request that says "call this function with these arguments."

The host is the program you write or run. It sends requests to the model, keeps the conversation, reads the output, and decides what to do with it. The host holds the real code for every tool.

The tools are functions the host is willing to run on the model's behalf: look up a file, query a database, fetch a page, send a message. Each tool has a name, a description, and a schema for its arguments.

The model proposes and the host disposes. Most of the security questions later in this article follow from that split.

Why the loop matters

A model with no tools can only answer from its context. Ask it for today's exchange rate and it will guess or say it does not know. Give it a weather tool and it can ask the host for a value, receive it, and answer from that value.

The loop also allows several steps. The model can search, read a result, decide that is not enough, fetch a link it found, and then summarize. Each step is a separate round trip, and the host controls every one of them: how many are allowed, which tools are offered, and what happens when a call fails.

How a tool call works

Every tool-use exchange follows the same sequence, whatever the API.

  1. The host sends the conversation and a list of tool definitions to the model.
  2. The model either answers with text or returns a structured tool call and marks the response as stopping for a tool.
  3. The host reads the call, checks it, and runs the matching function if it exists and policy allows it.
  4. The host adds the result to the conversation, referring to the call by its ID.
  5. The host sends the updated conversation back. The model either calls another tool or writes the final answer.

The loop ends when the model returns plain text, when the host reaches a turn limit, or when the host stops it.

Tool definitions are JSON Schema

A tool definition has a name, a description written for the model, and an input schema. The schema uses JSON Schema to declare each argument's type and which arguments are required. The model chooses tools by reading their descriptions, so a vague description produces wrong calls.

The model returns a structured call

The model does not run anything. It returns data naming the function and its arguments as a JSON object. Because the output is structured, the host does not have to guess a function name from free text.

Structured does not mean correct. The model can name a tool that does not exist, or pass a value that matches the schema but is wrong for the situation.

The host runs the tool

This step belongs to the host alone. The host looks up the name in its own registry. If the name is missing, it returns an error. If the name exists, the host still has to decide whether this call, with these arguments, for this user, may run. Only then does it execute the code.

The result goes back

The result is sent as a message that carries the ID of the original call. The model reads it as data and decides the next step. Treat that data as data, not instructions. A fetched web page can contain sentences that look like commands. This is the core of prompt injection, and the host must not let returned content change its rules.

Annotated request and response shapes

The shapes below follow two public tool-use references, checked on 2026-11-08. Model identifiers are placeholders. Field names are exact.

A request with one tool

json
{
  "model": "<model-id>",
  "max_tokens": 1024,
  "tools": [
    {
      "name": "get_weather",
      "description": "Get the current weather for a given location.",
      "input_schema": {
        "type": "object",
        "properties": {
          "location": {
            "type": "string",
            "description": "City and state, e.g. San Francisco, CA"
          }
        },
        "required": ["location"]
      }
    }
  ],
  "messages": [
    { "role": "user", "content": "What's the weather in San Francisco?" }
  ]
}
  • tools is the complete list of functions the model may call. Nothing outside it exists for the model.
  • name is what the model will return, and what the host uses to pick the function.
  • description is read by the model, so it should say what the tool does and when to use it.
  • input_schema is JSON Schema. required tells the model which arguments it must supply.

A response that asks for a tool

json
{
  "stop_reason": "tool_use",
  "content": [
    {
      "type": "tool_use",
      "id": "toolu_01A09q90qw90lq917835lq9",
      "name": "get_weather",
      "input": { "location": "San Francisco, CA" }
    }
  ]
}
  • stop_reason set to tool_use means the model is waiting for a result. A host that only reads text will miss the call.
  • A block with type set to tool_use is the structured call. Its id must be echoed back.
  • input should match the schema. Validate it anyway, because the model is not a schema engine.

The follow-up with the result

json
{
  "role": "user",
  "content": [
    {
      "type": "tool_result",
      "tool_use_id": "toolu_01A09q90qw90lq917835lq9",
      "content": "15 degrees Celsius, partly cloudy"
    }
  ]
}
  • tool_use_id must equal the call's id. A mismatch breaks the pairing.
  • Keep content short. Large dumps waste context and can carry injected text.

The same loop in the function-calling format

A second reference uses different names for the same idea. The tool has type set to function, and the model returns a function_call item whose arguments field is a JSON string the host must parse.

json
{
  "type": "function",
  "name": "get_weather",
  "description": "Retrieves current weather for the given location.",
  "parameters": {
    "type": "object",
    "properties": {
      "location": { "type": "string" }
    },
    "required": ["location"],
    "additionalProperties": false
  },
  "strict": true
}
json
{
  "type": "function_call",
  "id": "fc_12345xyz",
  "call_id": "call_12345xyz",
  "name": "get_weather",
  "arguments": "{\"location\":\"Paris, France\"}"
}
json
{
  "type": "function_call_output",
  "call_id": "call_12345xyz",
  "output": "{\"temperature\":\"25\",\"unit\":\"C\"}"
}

That reference states the rule directly: when the model calls a function, your application must execute it and return the result. Field names differ between endpoints and versions, so check before you copy a shape.

Why the host decides what runs

It is tempting to picture the model "using" a tool the way a person uses a calculator. That picture hides the important fact. The model writes text that describes a call. The host decides whether that text becomes an action.

That gives the host three jobs. It is the only place where code executes, so a delete function runs only when the host runs it. It is the only place where policy can be enforced: a line in the prompt saying "do not write files" is a request, while a missing write tool or a read-only database role is a control. And it controls scope, because it chooses which tools are offered on each turn. Offering fewer tools is the cheapest control you have.

The MCP specification describes the same split in protocol terms. The host is the LLM application, clients are connectors inside it, and servers expose tools. The spec says hosts must obtain explicit user consent before invoking any tool, and that tool descriptions such as annotations should be treated as untrusted unless they come from a trusted server. The specification page I checked on 2026-11-08 points to a revision dated 2026-07-28, so read the current revision before you build on it.

Why approval matters for side effects

Some tool calls only read. A search or a lookup changes nothing outside the host. Others change something: they send a message, edit a record, spend money, or delete data.

A wrong read gives a wrong answer that someone can check. A wrong write can be permanent, can reach other people, and may go unnoticed for a while. So a sound pattern is to split tools by effect and require an explicit decision from a person before any side-effecting call runs. The host shows the action in plain language with its real arguments, and waits for a yes.

Approve the specific call, not the session. A yes given once for "send email" should not quietly cover the next hundred messages with different recipients. Approval also fails when the reviewer does not look closely, so pair it with visible arguments, limits on what one call can do, and logs.

Excessive agency, the OWASP view

The OWASP Top 10 for LLM Applications lists excessive agency as LLM06:2025. The entry describes a vulnerability that lets damaging actions run in response to unexpected, ambiguous or manipulated model output, whatever the cause. Hallucination and prompt injection, direct or indirect, are common triggers.

It names three root causes: excessive functionality (tools the task does not need), excessive permissions (tools that can do more than the task requires), and excessive autonomy (high-impact actions with no check). The mitigations follow from those causes. Offer only the tools the task needs. Prefer narrow tools over general shells or arbitrary URL fetchers. Enforce minimum access in the downstream system, for example with database roles or narrow OAuth scopes. Run actions under the user's own authorization. Require human approval for high-impact actions. Enforce authorization downstream instead of trusting the model to decide.

Logging, monitoring and rate limiting are damage-limiting measures. They do not prevent the problem.

A local sketch of the loop

This sketch has no provider, no network call and no secret. The "model" is a fixed stub. It returns a tool call on its first turn and text after the result arrives. Its job is to show where the host sits. Replace the stub with a real API call in your own environment and keep the host logic.

Save this as loop_demo.py:

python
# Local sketch: no provider, no network. The "model" is a fixed stub that
# returns the same shape a provider returns for a tool call.
import json

def get_weather(location: str) -> str:
    return json.dumps({"location": location, "temp_c": 15, "sky": "partly cloudy"})

def delete_file(path: str) -> str:
    return json.dumps({"deleted": path})  # never reached in this demo

# The host decides what may run. The model only names a tool.
REGISTRY = {"get_weather": get_weather}          # delete_file is NOT registered
REQUIRES_APPROVAL = {"delete_file"}

def fake_model(messages):
    # Stub: first turn asks for weather, second turn answers with text.
    last = messages[-1]
    if last["role"] == "user" and isinstance(last["content"], str):
        return {"stop_reason": "tool_use",
                "content": [{"type": "tool_use", "id": "toolu_demo_1",
                             "name": "get_weather", "input": {"location": "Lisboa, PT"}}]}
    return {"stop_reason": "end_turn", "content": [{"type": "text", "text": "done"}]}

messages = [{"role": "user", "content": "Weather in Lisboa?"}]
for step in range(5):  # hard cap on turns
    reply = fake_model(messages)
    messages.append({"role": "assistant", "content": reply["content"]})
    if reply["stop_reason"] != "tool_use":
        print("final:", reply["content"][0]["text"])
        break
    results = []
    for block in reply["content"]:
        if block["type"] != "tool_use":
            continue
        name, args = block["name"], block["input"]
        if name not in REGISTRY:
            out, err = f"unknown tool {name}", True
        elif name in REQUIRES_APPROVAL:
            out, err = "approval required", True
        else:
            out, err = REGISTRY[name](**args), False
        print(f"host ran {name}({args}) -> {out}")
        results.append({"type": "tool_result", "tool_use_id": block["id"],
                        "content": out, "is_error": err})
    messages.append({"role": "user", "content": results})

Run it from a folder that holds only this file:

bash
python -I loop_demo.py

The -I flag starts Python in isolated mode. It ignores environment variables and keeps the current folder off the import path, a useful habit for code you did not write.

On a Windows 11 machine with Git Bash and Python 3.12.10, the run printed:

text
host ran get_weather({'location': 'Lisboa, PT'}) -> {"location": "Lisboa, PT", "temp_c": 15, "sky": "partly cloudy"}
final: done

The stub returned a tool_use stop reason and a call to get_weather. The host found the name in its registry, ran it, and wrote a tool_result back into the conversation. The stub then answered with text and the loop ended. delete_file never ran because the host never registered it. To see the approval branch, have the stub request delete_file and watch the host refuse it.

Common mistakes

Offering every tool in the codebase. The model uses what it sees, and so does any text it reads. Start with a short list and add tools one at a time.

Writing vague descriptions. "Does things with files" invites wrong calls. Say what the tool reads or writes and when not to use it.

Trusting arguments because they passed the schema. Schema checks confirm shape, not meaning. A well-formed path can still point outside the folder you meant to allow.

Trusting returned content. A tool result is data. Pasting it into the next prompt as if it were instructions gives a web page the power to steer your host.

Leaving out a turn limit. A loop with no cap can repeat a failing call until it costs real money.

Approving a whole session. Approve specific calls with their arguments visible.

Logging the wrong things. Record tool names, decisions and results with care for personal data. Full prompts and responses in logs can hold data you were not meant to keep.

How to go further

Build a loop with a real API key and one read-only tool, such as a function that returns the current time. Inspect the request and response bodies and find the tool_use block and the tool_result you return.

Then add one tool that writes to a scratch file in a folder you own, and put an approval step in front of it. Ask the model for a path outside that folder and check that your host stops the call.

Read the MCP specification sections on tools and security, and the OWASP entry for excessive agency in full. Map each mitigation to a line in your code.

Sources

Checked on 2026-11-08. Documentation pages move, so confirm the current version before you rely on a field name.

Sources

  1. Tool use overview, public API documentation
  2. Function calling guide, public API documentation
  3. OWASP Top 10 for LLM Applications: LLM06:2025 Excessive Agency
  4. Model Context Protocol specification

More in AI agents for security

AI agents for security

What Is MCP? The Model Context Protocol Explained

MCP is the open protocol that connects AI apps to tools and data. Learn its roles, primitives, transports and security model with a sandboxed lab.

Intermediate12 min read