Skip to content

3 · Tools and their schemas

What this step adds: the tools, their JSON schemas, and the registry that maps names to functions. The model can now ask for things. Nothing answers yet.

In Part 1 the schema was generated for you — you passed the function object and the library read its docstring and type hints. Part 2 writes the schemas by hand, and the difference is worth understanding.

A tool schema is the JSON description of a function that gets sent to the model: its name, what it does, and what arguments it takes. It is the only thing the model ever sees. Your function body is invisible to it.

SCHEMAS = {
"get_customer_profile": {
"type": "function",
"function": {
"name": "get_customer_profile",
"description": "Retrieve the customer's profile: home country, "
"average transaction amount, and when their contact "
"details were last changed.",
"parameters": {
"type": "object",
"properties": {
"customer_id": {"type": "string",
"description": "e.g. CUST-1001"},
},
"required": ["customer_id"],
},
},
},
# ... one entry per tool
}
TOOLS = {
"get_customer_profile": get_customer_profile,
"check_device_fingerprint": check_device_fingerprint,
"get_transaction_history": get_transaction_history,
"search_similar_cases": search_similar_cases,
}

Because the description is the interface, and you want to control it.

Auto-generation ties the model’s view of a tool to your docstring. That is convenient right up until you want to change how the model understands a tool without changing how a developer reads the code — or until a refactor renames a parameter and silently changes what the model gets told.

Writing them out makes the contract explicit and reviewable. In a regulated setting that matters for a blunt reason: SCHEMAS is the complete, auditable list of everything this system can do. Someone can read that dictionary and know the answer to “what actions can this agent take?” without reading any Python.

Notice TOOLS and SCHEMAS are separate dictionaries with the same keys. That is the Part 1 registry idea, unchanged — the model returns a string, and only a key that exists in TOOLS becomes a callable. A name that isn’t there gets an error message back, not an execution.

Tool Returns
get_customer_profile Home country, average transaction amount, contact-change date
check_device_fingerprint Whether this device is known to this customer, trust score
get_transaction_history Recent transactions, average amount
search_similar_cases Past cases with overlapping signals, and how they resolved

They all read from the fixtures and return dictionaries. There is no cleverness in them at all, and that is deliberate: a tool should be the least interesting code in the system. Every bit of judgment you put inside a tool is judgment that becomes invisible to the audit trail.

Terminal window
cd part2
uv run --with ollama run.py

The audit output now shows the model requesting tools:

ENRICHMENT | tool_call | get_customer_profile({'customer_id': 'CUST-1001'})

And then nothing. No result line, because nothing runs the request.

The gap between tool_call and a result is the same gap Part 1 opened up, and it is worth seeing again at this scale. The model produced JSON describing a call it would like made. Your code has not agreed to make it.

Everything a bank would want to insert — a permission check, a rate limit, a “does this analyst have access to this customer” test — goes in that gap. Which is why the next step is not simply “call the function”.

The requests go nowhere. Next: the loop that answers them, which you already know — and one line inside it that Part 1 never needed.