After this chapter, you will be able to add safe, well-scoped Python tools to a Microsoft Agent Framework agent so it can act beyond text generation.
You will use the verified agent-framework==1.9.0 API: decorate a Python function with @tool, inspect the generated schema, and pass the tool to Agent(..., tools=[...]).
In Chapter 1, tool use was the part of the agent loop that lets a model move from responding to acting. Microsoft Learn describes tools as controlled capabilities that let agents interact with external systems, execute code, search data, and more. Source
Function calling is the common mechanism behind many local tools. Instead of asking the model to invent an answer for something your application already knows how to compute, you expose a typed function with a name, description, and JSON-schema parameters. The model can request a call; the framework validates the arguments, invokes your Python function, and returns the result to the model as context for the final response.
This keeps the agent boundary from Chapter 3 intact: instructions describe what the agent should do, while tools define what it is allowed to do. A well-designed tool is a narrow capability with clear inputs, predictable outputs, and explicit safety boundaries.
In MAF Python, the standard path is the public agent_framework.tool decorator. Import it with from agent_framework import tool, decorate a plain Python function, and pass the resulting tool to an Agent. The decorator creates a FunctionTool, builds a Pydantic input model from the function signature, validates model-provided arguments, and generates JSON schema. The API confirmed for this chapter is agent-framework==1.9.0.
A minimal function tool attached to an agent.
from typing import Annotated
from agent_framework import Agent, tool
@tool
def add(a: Annotated[int, "left operand"], b: Annotated[int, "right operand"] = 1) -> int:
"""Add two integers."""
return a + b
agent = Agent(client, instructions="Use tools for arithmetic.", tools=[add])
The schema comes from normal Python parts, so tool authoring feels like writing ordinary application code with better descriptions:
Python function part
Tool schema role
Why it matters
def add(...)
Tool name, unless you override name=
The model chooses tools by name, so use a clear verb-noun name.
Docstring
Tool description, unless you override description=
The model uses the description to decide when the tool is appropriate.
Type hints such as int
JSON-schema parameter types
Argument validation catches mismatches before your function runs.
Annotated[int, "left operand"] or Pydantic Field
Parameter descriptions
Descriptions reduce ambiguous or malformed tool calls.
Default values such as b = 1
Optional parameters/defaults
Defaults let you keep common cases simple without broadening the tool.
Return value
Tool result content returned into the invocation loop
The model uses the result to produce the final response.
Stable tools belong on the agent constructor with Agent(..., tools=[...]). Per-request tools can also be supplied to Agent.run(..., tools=[...]). Advanced controls such as FunctionTool, normalize_tools, ToolMode, and function invocation configuration exist for explicit schemas, testing, or policy control, but the decorator is the normal implemented-function path.
Use a tool when the agent needs a capability that plain text instructions cannot safely or reliably provide.
Use tools for grounded facts and actions: calculations, lookups, domain APIs, file/search operations, or controlled business functions.
Use plain instructions for behavior: tone, role, response format, escalation rules, and constraints that do not require external action.
Use a workflow instead when the path is known: Microsoft Learn distinguishes agent-driven steps from workflows with explicit control over execution order. Source
Common pitfalls
Vague descriptions and poor schemas: if names, docstrings, or parameter descriptions are ambiguous, the model may choose the wrong tool or supply weak arguments.
Unsafe or over-broad tools: expose the smallest capability that solves the task. Avoid generic tools such as "run arbitrary command" unless you have strong isolation and approval.
Hidden side effects: make writes, sends, purchases, deletes, and other irreversible operations explicit. Microsoft documents tool approval as a way to gate tool invocations through human review. Source
Tool overload: too many overlapping tools make selection harder. Split capabilities across specialized agents or workflows only when a single agent becomes unreliable.
Assuming tools make outputs correct: tools provide controlled data or actions, but your application still needs testing, validation, responsible AI mitigations, and observability. Source
The verified example below defines a local arithmetic tool, inspects the generated schema, invokes the tool directly, normalizes it for framework use, and gives it to an Agent. The final live model call is clearly marked because it requires OPENAI_API_KEY.
backend/examples/ch05/tools_function_calling.py demonstrates @tool, generated schema, direct invocation, and Agent(..., tools=[...]).
"""Chapter 5: Function tools and guarded agent tool use."""
from __future__ import annotations
import asyncio
import os
from typing import Annotated
from agent_framework import Agent, FunctionTool, normalize_tools, tool
from agent_framework.openai import OpenAIChatClient
@tool
def add(
a: Annotated[int, "left operand"],
b: Annotated[int, "right operand"] = 1,
) -> int:
"""Add two integers."""
return a + b
async def main() -> None:
schema = add.to_dict()
print(schema["name"], schema["input_model"]["properties"]["a"]["description"])
print(add(2, b=3))
invoked = await add.invoke(arguments={"a": 10, "b": 5})
print(invoked)
normalized = normalize_tools([add])
assert isinstance(normalized[0], FunctionTool)
client = OpenAIChatClient(
model="gpt-4o-mini",
api_key=os.getenv("OPENAI_API_KEY") or "placeholder",
)
agent = Agent(client, instructions="Use tools for arithmetic.", tools=[add])
if not os.getenv("OPENAI_API_KEY"):
print(f"Constructed agent {agent.name!r} with tool {add.name!r}; skipping live call.")
return
# requires OPENAI_API_KEY
response = await agent.run("What is 10 + 5?")
print(response.text)
if __name__ == "__main__":
asyncio.run(main())
Walk through it in three steps. First, @tool turns add into a FunctionTool; its signature and Annotated descriptions become the input schema. Second, add.to_dict(), add(...), and await add.invoke(...) show that the tool is inspectable and testable without a live model. Third, Agent(client, instructions=..., tools=[add]) makes the tool available to the model; the script skips the live agent.run call unless OPENAI_API_KEY is set.