Tools and tool-calling
Hookโ
An internal assistant is asked, "clean up the stale rows in the staging table."
It emits an action that looks perfectly reasonable โ a database command with the
right table name โ and the runtime dutifully executes it against production,
with no WHERE clause. The model did not "go rogue." It proposed a call, and
nothing between the proposal and the execution checked whether that call was
allowed, well-formed, or bounded. This lesson is about the layer that should
have been there.
Conceptโ
In the previous lesson, "act" was a single beat in the loop. Now open it up. An agent acts by calling a tool: a named, schema-described action the agent is allowed to take, with a defined set of arguments and a defined effect. A calculator is a tool. A "search the docs" function is a tool. A "write this file" function is a tool. The agent never runs arbitrary code; it picks from a fixed menu of tools you registered.
Every tool has a tool schema โ a machine-readable description of how to call it. At minimum a schema carries three things:
| Part | What it says |
|---|---|
| Name | The identifier the agent uses to select the tool |
| Description | What the tool does, so the agent knows when to reach for it |
| Parameters | Each argument's name, type, whether it is required, and any allowed values |
When the agent decides to act, it does not run anything itself. It emits a
tool call: a structured object naming a tool and supplying arguments โ for
example, {"tool": "convert_temp", "args": {"value": 37.0, "to_unit": "F"}}.
That tool call is an observable artifact, exactly like the plan from the
last lesson. You can read it, log it, and check it. Turning the agent's raw
output into that structured object is tool-call parsing, and the runtime โ
not the model โ owns everything that happens next.
What happens next, and what the hook was missing, is validation. Before a tool call runs, the runtime checks it against the schema and refuses anything that does not fit:
- The tool exists. A call naming an unregistered tool is rejected, not guessed at. This alone stops a whole class of hallucinated actions.
- Required arguments are present and every argument matches its declared type.
- Values are in range. A parameter with an allowlist (units, table names, file paths under a root) rejects anything outside it.
Validation is where you enforce bounded actions โ the practice of constraining not just whether a call is well-formed but how much it is allowed to do. Bounded actions are a safety property, and this course treats them as mandatory, not optional polish:
- Least privilege. Register the narrowest tool that does the job. A "read a row by id" tool cannot drop a table; an unbounded "run SQL" tool can.
- Read-only by default. Anything that mutates state โ writes, deletes, spend โ is a deliberate, separately guarded choice, ideally behind a human approval gate.
- Allowlists over blocklists. Enumerate what is permitted; do not try to list everything that is forbidden.
Hold on to the framing from the last lesson: the model proposes a tool call, and the runtime disposes. You debug and secure an agent from the tool calls and observations it produces โ the artifacts in the record โ never from an assumption about the model's private reasoning. A validator you can read is worth more than any claim about what the model "intended."
Worked exampleโ
Let me build the proposal-then-validation boundary in plain Python for a single tool โ a temperature converter โ and watch it accept a good call and refuse two bad ones. This is the exact layer the hook was missing.
TOOL_SCHEMAS = {
"convert_temp": {
"description": "Convert a Celsius temperature to C, F, or K.",
"params": {
"value": {"type": float, "required": True},
"to_unit": {"type": str, "required": True, "allowed": ["C", "F", "K"]},
},
},
}
def convert_temp(value, to_unit):
if to_unit == "F":
return value * 9 / 5 + 32
if to_unit == "K":
return value + 273.15
return value
def validate(call):
"""Check a proposed tool call against the schema before running it."""
name = call.get("tool")
if name not in TOOL_SCHEMAS:
return False, f"unknown tool: {name!r}"
args = call.get("args", {})
for param, rules in TOOL_SCHEMAS[name]["params"].items():
if rules["required"] and param not in args:
return False, f"missing required arg: {param!r}"
if param in args and not isinstance(args[param], rules["type"]):
return False, f"arg {param!r} must be {rules['type'].__name__}"
if "allowed" in rules and args.get(param) not in rules["allowed"]:
return False, f"arg {param!r} must be one of {rules['allowed']}"
return True, None
def call_tool(call):
"""Validate first; only run the tool if the call passes every check."""
ok, error = validate(call)
if not ok:
return {"ok": False, "error": error}
return {"ok": True, "result": convert_temp(**call["args"])}
good = {"tool": "convert_temp", "args": {"value": 37.0, "to_unit": "F"}}
bad_unit = {"tool": "convert_temp", "args": {"value": 37.0, "to_unit": "Mars"}}
bad_tool = {"tool": "delete_database", "args": {}}
print(call_tool(good))
print(call_tool(bad_unit))
print(call_tool(bad_tool))
{'ok': True, 'result': 98.6}
{'ok': False, 'error': "arg 'to_unit' must be one of ['C', 'F', 'K']"}
{'ok': False, 'error': "unknown tool: 'delete_database'"}
Read what the boundary did. The good call passed validation and returned a
result. The second call named a real tool but supplied an out-of-allowlist unit,
so it was refused before convert_temp ever ran โ a bounded action rejecting a
value outside its permitted set. The third call named a tool that does not
exist, and it was rejected outright; there is no delete_database on the menu,
so the agent simply cannot invoke one. Every decision here lives in the runtime,
in code you can read, not in the model.
Hands-onโ
Now design a tool the other way around โ schema first โ and watch how the agent's calls change as you tighten it. The exercise below opens in the Agent Builder with an agent and one editable tool schema.
Do this: give the tool a clear name and description, declare one required parameter with an allowlist, then run the agent and read the emitted tool call. Now feed it a request that would need a value outside the allowlist and confirm the call is rejected, not executed. Success criteria: you can point to the tool call in the trace, name each field of your schema it had to satisfy, and explain which check refused the out-of-bounds request.
Recapโ
- You can define a tool as a named, schema-bound action, and list the three parts of a tool schema: name, description, and typed parameters.
- You can describe the path of a tool call: the agent proposes a structured call, the runtime parses it, and validation checks it before anything runs.
- You can name the validation gates โ tool exists, required and well-typed arguments, values in range โ and say which hallucinated actions each stops.
- You can explain bounded actions โ least privilege, read-only defaults, allowlists โ as a safety boundary the runtime enforces, not the model.
Next up, you will step back out to the whole loop and ask how an agent strings tool calls together across many steps: how it holds state in memory and how it breaks a goal into an ordered plan.
- A tool is a named, schema-bound action; the agent picks from a fixed menu of registered tools and never runs arbitrary code. - A tool call is an observable artifact โ a tool name plus arguments โ that the runtime parses and validates (tool exists, required and typed args, values in range) before it runs. - Bounded actions โ least privilege, read-only defaults, allowlists โ are a safety boundary enforced in the runtime you control, not a behavior you hope for from the model.