Agents & tools¶
An LLM on its own can only produce text. To do things — look up a record, open a ticket, call an internal API, read a web page — it needs tools. Most of the craft in agentic AI is choosing the right tools and letting the model decide when to use them.
In Kaval.AI a tool is a Python function, a REST endpoint or an MCP tool, all reached through one type-checked interface. Two pieces work together:
The
FunctionKernelhosts tools and calls them, validating every input and output against a Pydantic model.The
Agentis a small reasoning loop: it picks tools, calls them through the kernel, reads the results, and repeats until it has an answer.
This notebook builds tools from plain functions, hands them to an agent, then shows the REST and MCP tool kinds and the tools Kaval.AI ships with.
Setup¶
import sys
import dotenv
from loguru import logger
dotenv.load_dotenv("../.env")
logger.remove()
_ = logger.add(sys.stderr, level="WARNING")
Your first tools¶
A tool is an ordinary function decorated with @pythontool. The decorator marks
it as a Kaval.AI tool but does not change its behaviour — the function stays
directly callable, so it is still testable on its own.
Register it on a FunctionKernel and it becomes reachable
through a python://<name> URI.
from pydantic import BaseModel
from kavalai import FunctionKernel, pythontool
class Resident(BaseModel):
name: str
born: str
street: str
VILLAGE = {
"Agnes Whitlow": Resident(
name="Agnes Whitlow", born="1929-06-02", street="Willow Lane"
),
"Thomas Cook": Resident(
name="Thomas Cook", born="1994-04-12", street="Main Road"
),
"Greta Lindqvist": Resident(
name="Greta Lindqvist", born="1968-11-27", street="Cobbler's Path"
),
}
@pythontool
def find_resident(name: str) -> Resident:
"""Look up a Green Village resident by name."""
return VILLAGE[name]
@pythontool
def village_population() -> int:
"""Return the number of residents of Green Village."""
return 104
# A @pythontool function is unchanged — you can still call it directly.
print("direct call:", find_resident("Agnes Whitlow"))
kernel = FunctionKernel()
kernel.register_python_tool("find_resident", find_resident)
kernel.register_python_tool("village_population", village_population)
# Through the kernel, each tool is addressed by URI.
resident = await kernel.call_tool(
"python://find_resident", {"name": "Greta Lindqvist"}
)
population = await kernel.call_tool("python://village_population")
print("via kernel :", resident)
print("street :", resident.street)
print("population :", population.result)
direct call: name='Agnes Whitlow' born='1929-06-02' street='Willow Lane'
via kernel : name='Greta Lindqvist' born='1968-11-27' street="Cobbler's Path"
street : Cobbler's Path
population : 104
The tool name is what the kernel exposes; dotted names like village.find
are a convention for grouping related tools.
What a tool may return¶
There are only two shapes:
Return annotation |
What |
|---|---|
a Pydantic model |
that model, fields readable directly ( |
anything else — |
a generated wrapper, value under |
Prefer a Pydantic model for anything structured. A bare dict still arrives
intact under .result, but it carries no schema the kernel can describe to the
model or validate against — you lose exactly the guarantee the kernel exists to
give. A value that cannot satisfy the model raises FunctionKernelException
rather than being quietly passed through.
Typed inputs and outputs¶
The kernel builds an input and an output Pydantic model from each function’s signature, so an agent always works with validated data:
every parameter becomes an input field — the type hint is the field type, and a default makes it optional;
the return annotation becomes the output type, wrapped in a
resultfield unless the function already returns a Pydantic model.
Because validation runs on the way in, arguments are coerced to the declared
types. A model that emits the string "1929" still hands your function an
int.
@pythontool
def years_since(year: int) -> int:
"""Return how many years have passed since the given year (as of 2026)."""
return 2026 - year
kernel.register_python_tool("years_since", years_since)
input_model = kernel.get_input_model("python://years_since")
output_model = kernel.get_output_model("python://years_since")
print("input fields :", list(input_model.model_fields))
print("output fields:", list(output_model.model_fields))
# The string "1929" is coerced to an int before the function runs.
age = await kernel.call_tool("python://years_since", {"year": "1929"})
print('years_since("1929") =', age.result)
input fields : ['year']
output fields: ['result']
years_since("1929") = 97
What the model is shown¶
Before each step the kernel renders its tools as JSON and puts them in the prompt. This is worth looking at once — it is why docstrings and type hints matter so much. They are the interface the model programs against.
print(await kernel.get_tool_descriptions())
[
{
"name": "python://find_resident",
"description": "Look up a Green Village resident by name.",
"inputSchema": {
"properties": {
"name": {
"title": "Name",
"type": "string"
}
},
"required": [
"name"
]
},
"outputSchema": {
"properties": {
"name": {
"title": "Name",
"type": "string"
},
"born": {
"title": "Born",
"type": "string"
},
"street": {
"title": "Street",
"type": "string"
}
},
"required": [
"name",
"born",
"street"
]
}
},
{
"name": "python://village_population",
"description": "Return the number of residents of Green Village.",
"inputSchema": {
"properties": {}
},
"outputSchema": {
"properties": {
"result": {
"title": "Result",
"type": "integer"
}
},
"required": [
"result"
]
}
},
{
"name": "python://years_since",
"description": "Return how many years have passed since the given year (as of 2026).",
"inputSchema": {
"properties": {
"year": {
"title": "Year",
"type": "integer"
}
},
"required": [
"year"
]
},
"outputSchema": {
"properties": {
"result": {
"title": "Result",
"type": "integer"
}
},
"required": [
"result"
]
}
}
]
Agents: tools in a loop¶
An Agent wraps an LLM client and a kernel into a reasoning
loop. Each step performs four operations:
Render a prompt containing the task, the tool descriptions and the history of previous steps.
Reason — the model returns the tool calls it wants plus an optional final answer.
Act — those calls run in parallel through the kernel, and the results feed into the next step.
Decide — the loop ends when the model answers with no further tool calls, or when
max_stepsis reached.
That bound is a safety feature: an agent cannot loop forever, and it can only act through tools you registered.
Pass a response_model and the final answer is validated into your type.
from kavalai import Agent, make_client
class ResidentReport(BaseModel):
name: str
street: str
age: int
agent = Agent(llm_client=make_client("openai/gpt-5.6-luna"), kernel=kernel)
report = await agent.prompt(
"Look up Agnes Whitlow of Green Village. Then, using her birth "
"year from that lookup, work out how old she is. Report her "
"name, street and age.",
response_model=ResidentReport,
max_steps=5,
)
print(report)
name='Agnes Whitlow' street='Willow Lane' age=97
That took two steps: the model called find_resident, then used the returned
birth year with years_since before answering. Without a response_model the
agent returns a plain string instead.
Pass debug=True to the Agent constructor to see each step’s reasoning and
tool calls as it runs.
Restricting what an agent may use¶
One kernel often hosts more tools than any single agent should touch.
allowed_tools narrows it: the excluded tools are neither described to the
model nor callable, so restriction is enforced, not merely suggested.
Entries are tool URIs. proto://server.* allows a whole server and "*"
allows everything; None (the default) means the same as "*", and an empty
list allows nothing. YAML agent nodes read the same values the same way.
import json
restricted = await kernel.get_tool_descriptions(
allowed_tools=["python://village_population"]
)
print("visible to a restricted agent:",
[t["name"] for t in json.loads(restricted)])
readonly_agent = Agent(
llm_client=make_client("openai/gpt-5.6-luna"),
kernel=kernel,
allowed_tools=["python://village_population"],
)
print(await readonly_agent.prompt(
"How many people live in Green Village?", max_steps=3
))
visible to a restricted agent: ['python://village_population']
104 people live in Green Village.
REST tools¶
A REST tool wraps an HTTP endpoint. Register the server with its base URL, then each endpoint with its method and JSON schemas. The kernel builds the same Pydantic models it builds for Python tools, so a REST call is validated exactly like a local one.
This uses the free Open-Meteo API — no key required — to fetch the current temperature in Tallinn.
from kavalai import RestServer
kernel.register_rest_server(
RestServer(name="weather", url="https://api.open-meteo.com/v1")
)
kernel.register_rest_tool(
server_name="weather",
tool_name="forecast",
method="GET",
input_schema={
"type": "object",
"properties": {
"latitude": {"type": "number"},
"longitude": {"type": "number"},
"current": {"type": "string", "description": "e.g. temperature_2m"},
},
"required": ["latitude", "longitude"],
},
output_schema={
"type": "object",
"properties": {"current": {"type": "object"}},
},
description="Current weather for a GPS location from Open-Meteo.",
)
# The coordinates below are Tallinn's.
weather = await kernel.call_tool(
"rest://weather.forecast",
{"latitude": 59.437, "longitude": 24.7536, "current": "temperature_2m"},
)
print(weather.current)
{'time': '2026-08-28T11:45', 'interval': 900, 'temperature_2m': 22.7}
An agent can now reach that endpoint by URI, the same way it reaches a Python function:
weather_agent = Agent(
llm_client=make_client("openai/gpt-5.6-luna"),
kernel=kernel,
allowed_tools=["rest://weather.*"],
)
print(await weather_agent.prompt(
"What is the current temperature in Tallinn "
"(59.437 N, 24.7536 E)?",
max_steps=3,
))
The current temperature in Tallinn (59.437° N, 24.7536° E) is 22.7 °C, according to the latest reading at 2026-08-28 11:45.
MCP tools¶
The Model Context Protocol is an open standard for exposing tools to LLM applications. Point the kernel at an MCP server and it starts the process, discovers the tools it offers over stdio, and routes calls to them — you do not describe them by hand.
Here is a minimal MCP server written to a temporary file:
import sys
import tempfile
import textwrap
from pathlib import Path
server_path = Path(tempfile.gettempdir()) / "green_village_mcp.py"
server_path.write_text(textwrap.dedent('''
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("green-village")
@mcp.tool()
def honey_per_hive(total_tons: float, hives: int) -> float:
"""Kilograms of honey produced per beehive per year."""
return (total_tons * 1000) / hives
@mcp.tool()
def loaves_per_resident(loaves_per_week: int, residents: int) -> float:
"""Weekly loaves baked per resident."""
return loaves_per_week / residents
if __name__ == "__main__":
mcp.run()
'''))
from kavalai import McpServer
mcp_kernel = FunctionKernel()
mcp_kernel.register_mcp_server(
McpServer(name="village", command=sys.executable, args=[str(server_path)])
)
# Registration only records the server. Connecting starts the process and asks
# it what it offers — so a misconfigured server fails here, before any tokens
# are spent on a run that was never going to work.
await mcp_kernel.connect_mcp_servers()
honey = await mcp_kernel.call_tool(
"mcp://village.honey_per_hive", {"total_tons": 8, "hives": 26}
)
print("kg of honey per hive:", round(honey.result, 1))
kg of honey per hive: 307.7
An MCP server is a separate process, so its tools have to be asked for rather
than read from configuration. connect_mcp_servers() does that, and calling it
up front is worth it: a server that will not start says so at startup instead of
half way through a run.
You are not obliged to. get_tool_descriptions() connects anything still
unconnected before it answers, and a tool call connects on demand, so an agent
is never handed an MCP server it cannot see. WorkflowEngine wraps both ends as
await engine.connect() and await engine.aclose().
# Connected, so the server's tools are described to the model.
print(await mcp_kernel.get_tool_descriptions())
[
{
"name": "mcp://village.honey_per_hive",
"description": "Kilograms of honey produced per beehive per year.",
"inputSchema": {
"properties": {
"total_tons": {
"title": "Total Tons",
"type": "number"
},
"hives": {
"title": "Hives",
"type": "integer"
}
},
"required": [
"total_tons",
"hives"
]
},
"outputSchema": {
"properties": {
"result": {
"title": "Result"
}
},
"required": [
"result"
]
}
},
{
"name": "mcp://village.loaves_per_resident",
"description": "Weekly loaves baked per resident.",
"inputSchema": {
"properties": {
"loaves_per_week": {
"title": "Loaves Per Week",
"type": "integer"
},
"residents": {
"title": "Residents",
"type": "integer"
}
},
"required": [
"loaves_per_week",
"residents"
]
},
"outputSchema": {
"properties": {
"result": {
"title": "Result"
}
},
"required": [
"result"
]
}
}
]
Green Village produces 8 tons of honey a year from 26 hives, and its bakery sells 340 loaves a week to 104 residents. Let the agent do the arithmetic through the MCP tools:
mcp_agent = Agent(
llm_client=make_client("openai/gpt-5.6-luna"), kernel=mcp_kernel
)
print(await mcp_agent.prompt(
"Use the available tools to work out, for Green Village: "
"kilograms of honey per beehive (8 tons a year from 26 hives) "
"and loaves per resident (340 loaves a week for 104 residents). "
"Round each to one decimal.",
max_steps=4,
))
Green Village produces **307.7 kilograms of honey per beehive per year** and **3.3 loaves per resident per week**.
# MCP servers are subprocesses — shut them down when you are done.
# A notebook runs every cell as its own task, and the MCP transport must be
# closed by the task that opened it, so the kernel logs a warning here; the
# subprocess is stopped all the same. An application connects and closes in
# one place (`engine.connect()` / `engine.aclose()`), where it is silent.
await mcp_kernel.close()
2026-08-28 14:53:18.850 | WARNING | kavalai.functionkernel:close:380 - Error during MCP cleanup: Attempted to exit cancel scope in a different task than it was entered in
2026-08-28 14:53:19.195 | WARNING | kavalai.functionkernel:close:380 - Error during MCP cleanup: Attempted to exit cancel scope in a different task than it was entered in
Tools that ship with Kaval.AI¶
You do not have to write everything yourself. kavalai[common] bundles several
ready-made tools; register them like any other Python tool.
http_request is the general-purpose one — any HTTP call, with optional basic
auth and an optional Tor proxy:
from kavalai.tools.webtools.http_client import http_request
web_kernel = FunctionKernel()
web_kernel.register_python_tool("http.request", http_request)
response = await web_kernel.call_tool(
"python://http.request",
{
"method": "GET",
"url": "https://api.open-meteo.com/v1/forecast",
"params": {
"latitude": 59.437,
"longitude": 24.7536,
"current": "temperature_2m",
},
},
)
print("HTTP", response.status_code, "->", response.json_data["current"])
HTTP 200 -> {'time': '2026-08-28T11:45', 'interval': 900, 'temperature_2m': 22.7}
The rest of the bundled set:
Tool |
Import |
What it does |
|---|---|---|
|
|
renders a page in a headless browser, returns clean Markdown |
|
|
web search with no API key (scrapes the DuckDuckGo HTML endpoint) |
|
|
any HTTP request |
crawl_url and web_search drive a real headless browser, so they are slower
than the calls above and are not run in this notebook. A worked example that
combines them lives in examples/business_info_agent/: a search node finds
candidate pages, an agent node restricted to the crawl tool reads the promising
ones, and an LLM node writes the summary.
from kavalai import FunctionKernel
from kavalai.tools.webtools.crawl4ai import crawl_url, web_search
kernel = FunctionKernel()
kernel.register_python_tool("web.search", web_search)
kernel.register_python_tool("web.crawl", crawl_url)
Where to next¶
Workflows — put an agent inside a typed, branching graph as an
agentnode.LLM clients — the clients that power an agent’s reasoning.
Tools — the concepts behind the kernel.
Safety — bounded loops, explicit capabilities and typed I/O.
Bundled tools — every bundled tool, argument by argument.