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 FunctionKernel hosts tools and calls them, validating every input and output against a Pydantic model.

  • The Agent is 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 call_tool gives you

a Pydantic model

that model, fields readable directly (resident.street)

anything else — int, str, dict, a list

a generated wrapper, value under .result

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 result field 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:

  1. Render a prompt containing the task, the tool descriptions and the history of previous steps.

  2. Reason — the model returns the tool calls it wants plus an optional final answer.

  3. Act — those calls run in parallel through the kernel, and the results feed into the next step.

  4. Decide — the loop ends when the model answers with no further tool calls, or when max_steps is 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

crawl_url

kavalai.tools.webtools.crawl4ai

renders a page in a headless browser, returns clean Markdown

web_search

kavalai.tools.webtools.crawl4ai

web search with no API key (scrapes the DuckDuckGo HTML endpoint)

http_request

kavalai.tools.webtools.http_client

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 agent node.

  • 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.