You check one model-proposed read call before dispatch. The recorded proposal reads rollback.md; separate recorded checks reject an unoffered tool and a page outside the allowed names.
Until now the application decided what went into the prompt. With tool calling, the model asks for it: the application describes functions it is willing to run, the model replies with a call such as read_page("rollback.md") instead of an answer, and the application runs the function and sends back the result. Here the docs assistant proposes one read call, and the application checks it before running code. An agent framework can manage repeated model turns; this lab stops after one model response.
When you need this. The docs assistant should look up the page it needs instead of receiving every page up front, and later it will create tokens or open issues. The moment a model can trigger code, you need a clear line between what the model proposes and what your application allows.
1. Write the tools
Download the tools-and-permissions lab and extract its code, docs, tests and README together.
unzip tools-and-permissions.zip
cd tools-and-permissions
python3.12 -m venv .venv
.venv/bin/python -m pip install -r requirements-lock.txt
# Offline tests: no model calls
.venv/bin/python -m pytest -q
# Run after reading the implementation below
ollama pull gemma4:e2b
.venv/bin/python single_call.pyThe tests exercise argument and offered-tool checks without inference. single_call.py sends one request to Ollama.
"""A docs assistant that calls tools: the model proposes calls, the application validates and runs them.
Run: python docs_tools.py"""
import json
from pathlib import Path
from typing import Annotated, Literal
import ollama
from pydantic import Field, TypeAdapter, ValidationError, validate_call
MODEL = "gemma4:e2b"
DOCS = {page.name: page.read_text() for page in sorted((Path(__file__).parent / "docs").glob("*.md"))}
Page = Literal["config.md", "deploy.md", "rollback.md", "tokens.md"]
@validate_call
def search_docs(query: Annotated[str, Field(min_length=2, description='Words to look for, such as "rollback".')]) -> list[dict]:
"""Search the docuctl docs and return matching page names with their titles."""
words = {word.lower() for word in query.split()}
hits = [(sum(word in text.lower() for word in words), name) for name, text in DOCS.items()]
return [{"page": name, "title": DOCS[name].splitlines()[0].lstrip("# ")}
for score, name in sorted(hits, reverse=True) if score]
@validate_call
def read_page(page: Annotated[Page, Field(description="A page name returned by search_docs.")]) -> str:
"""Return the full text of one docuctl docs page."""
return DOCS[page]
TOOLS = {tool.__name__: tool for tool in (search_docs, read_page)}
def definition(tool) -> dict:
"""What the model sees: name, docstring and the Pydantic argument schema, enums included."""
return {"type": "function", "function": {"name": tool.__name__, "description": tool.__doc__,
"parameters": TypeAdapter(tool).json_schema()}}
def run_tool(name: str, arguments: dict, offered: set[str] | None = None) -> str:
"""The application's side of a tool call: only known tools, only valid arguments."""
if offered is not None and name not in offered:
return f"error: tool {name!r} was not offered in this request"
if name not in TOOLS:
return f"error: unknown tool {name!r}; available tools: {', '.join(TOOLS)}"
try:
return json.dumps(TOOLS[name](**arguments))
except ValidationError as error:
return f"error: invalid arguments for {name}: {error.errors()[0]['msg']}"
The tools are ordinary Python functions. @validate_call makes Pydantic check every argument against the type hints when the function is called, and Annotated[..., Field(...)] adds constraints and descriptions. read_page takes a Literal of the four page names. The model can still propose another filename; Pydantic rejects that argument when the application validates the call, before the tool reads a file.
2. Describe the tools to the model
The request sends tool definitions rather than the function bodies. It sees a tool definition: a name, a description and a JSON schema of the arguments. definition() builds it from the function’s docstring and TypeAdapter(tool).json_schema(), the same schema Pydantic validates against, so both derive their argument shape from the same type hints. Descriptions and runtime authorization still need separate review. The first line of the recorded run prints the page schema with its enum:
read_page schema: {"page": {"description": "A page name returned by search_docs.", "enum": ["config.md", "deploy.md", "rollback.md", "tokens.md"], "title": "Page", "type": "string"}}3. Inspect one proposed call
call_once() sends only read_page, then checks the proposals in that single response. A model may answer without calling it; an empty result list means that no function ran.
"""Inspect one model response and validate its proposed read calls. No agent loop.
Run: python single_call.py
"""
import json
import ollama
from docs_tools import MODEL, definition, read_page, run_tool
QUESTION = "Read rollback.md from the docs. Use the read_page tool."
def call_once(client, question: str, tools: list | None = None) -> list[dict]:
definitions = [definition(read_page)] if tools is None else tools
offered = {item["function"]["name"] for item in definitions}
reply = client.chat(model=MODEL, messages=[{"role": "user", "content": question}],
tools=definitions, think=False, options={"temperature": 0, "num_predict": 256})
# No call is also a possible model response. Record it without executing anything.
return [{"name": call.function.name, "arguments": dict(call.function.arguments),
"result": run_tool(call.function.name, dict(call.function.arguments), offered)}
for call in reply.message.tool_calls or []]
if __name__ == "__main__":
print(json.dumps(call_once(ollama.Client(), QUESTION), indent=2))This saved local-model call used the original shipctl manual. Its returned page text remains unchanged; a current docuctl run reads the renamed manual.
[
{
"name": "read_page",
"arguments": {
"page": "rollback.md"
},
"result": "\"# Roll back a release\\n\\n`shipctl rollback` moves an environment back to an earlier release. It does not rebuild anything; it points traffic at the release you name.\\n\\n shipctl rollback --env production --to r41\\n\\nWithout `--to`, shipctl rolls back to the previous release. A rollback is itself recorded as a new release, so `shipctl releases` shows it.\\n\\nDatabase migrations are not rolled back. If the release you leave ran a migration, write a new migration that reverses it.\\n\\nshipctl 1.x used `shipctl release revert <release>`. That command was removed in shipctl 2.0.\\n\""
}
]The tool result stays in application code here. To ask the model for an answer from that result, an agent framework sends another model request. The Google ADK and LangGraph courses teach that workflow.
The application can check these failures without a model:
"""Check three proposed calls without a model. Run: python check_calls.py"""
from docs_tools import run_tool
if __name__ == "__main__":
for name, args, offered in [
("read_page", {"page": "rollback.md"}, set()),
("read_page", {"page": "secrets.md"}, {"read_page"}),
("delete_page", {"page": "rollback.md"}, {"read_page"}),
]:
print(name, args, "->", run_tool(name, args, offered))read_page {'page': 'rollback.md'} -> error: tool 'read_page' was not offered in this request
read_page {'page': 'secrets.md'} -> error: invalid arguments for read_page: Input should be 'config.md', 'deploy.md', 'rollback.md' or 'tokens.md'
delete_page {'page': 'rollback.md'} -> error: tool 'delete_page' was not offered in this requestA known tool was not offered in this request
A global registry is not a request permission list. The first check offers no tools, so it rejects even read_page. The last check rejects delete_page. Validate the proposed name against the tools offered for this request before dispatching it.
Optional: inspect the small tool-calling loop
The linked original recording used repeated model turns. answer() in the downloadable file appends tool responses and asks the model again, up to max_rounds. That bounds turn count, not wall time or total cost. Use an agent framework for the application workflow rather than growing this teaching loop.
def answer(client: ollama.Client, question: str, max_rounds: int = 4, tools: list | None = None) -> list[str]:
messages = [{"role": "system", "content": "You answer questions about docuctl. Use the tools, cite the page, and answer in at most two sentences."},
{"role": "user", "content": question}]
log = []
definitions = [definition(tool) for tool in TOOLS.values()] if tools is None else tools
offered = {item["function"]["name"] for item in definitions}
for _ in range(max_rounds):
reply = client.chat(model=MODEL, messages=messages, tools=definitions, think=False,
options={"temperature": 0})
messages.append(reply.message)
if not reply.message.tool_calls:
log.append(f"answer: {reply.message.content.strip()}")
return log
for call in reply.message.tool_calls:
result = run_tool(call.function.name, dict(call.function.arguments), offered)
log.append(f"call {call.function.name}({json.dumps(dict(call.function.arguments))}) -> {result[:90]}")
messages.append({"role": "tool", "tool_name": call.function.name, "content": result})
log.append(f"stopped: no answer after {max_rounds} rounds")
return log
The page argument fails validation
The middle check offers read_page, but the proposed secrets.md value is outside its allowed page names. Pydantic rejects it before the file read runs. An allowed function name does not make its arguments valid.
Dispatch only a validated, offered tool call
A tool call is still generated text. The runtime renders the tool definitions into the prompt through the chat template, the model generates a structured call in a format it was trained on, and the runtime parses it into tool_calls. Nothing runs inside the model or the runtime: execution happens only when your code calls the function.
That makes the application the place for every rule. run_tool() refuses any name that is not in TOOLS, validates arguments before the function body runs, and returns a validation error instead of running an invalid call. The application must pass the request-specific tool set, including an empty set when it offers no tools. These checks validate a read call; they do not authorize writes to an external service.
Try it: add a releases tool with a typed environment
Add list_releases(env: Literal["staging", "production"]) -> list[str] returning fixed release names, offer its definition in call_once(), and inspect the proposed arguments. Then call it with env="qa" and check that validation rejects the arguments before the function body runs. The offline tests script the model’s calls: .venv/bin/python -m pytest -q.
Read the explanation
The model only proposes calls. The application checks the tool name against what it offered, validates arguments, runs the function and sends the result back.
Defend a tool-using assistant against prompt injection checks what happens when a retrieved page asks for a write operation. The MCP course teaches shared tool servers after these fundamentals.
References
Checked October 21, 2026.