A tool registry for agents
Schemas from functions, validation, output limits and helpful errors.
Create a free account Try part of it here
Why it matters
Tools are the agent's hands, and the registry is where the harness keeps them. A registry does four jobs: describe each tool to the model, check what the model sends, run the tool, and shape what comes back. Each job has a classic failure. A vague description gets the wrong tool picked. A missing check runs a call with a bad argument. A huge output pushes everything else out of context. This lesson builds a registry that avoids all three.
What you'll be able to do
- Write tool definitions whose names, descriptions and schemas a model can use well
- Generate input schemas from Python function signatures
- Validate tool inputs before running them, with errors the model can act on
- Cap tool output so one call can't flood the context window
What it covers
- What the model sees
- A schema from a signature
- Errors are messages to the model
- Output budgets
- Keep the head and the tail
- When there are many tools
- Searching tools instead of sending them all
- A schema is not a guarantee
What the model sees
Picture a new hire handed a cabinet of labeled drawers, who isn't allowed to open any until they've picked one. All they have is the labels. For each tool, the model gets three things: a name, a description and an input schema (JSON Schema). That is all it knows. It never sees your code. So the description must say:
- what the tool does and when to use it (and when not to);
- what each parameter means, with formats ("ISO date, like 2026-03-01");
- what comes back, and any limits ("returns at most 50 lines").
Compare these two:
| weak | strong |
|---|---|
search: "Searches." | search_code: "Find lines matching a regular expression in the project. Use it to find where a name is defined or used. Returns up to 50 matches as path:line: text. Searches only inside the project folder." |
Names matter too. Use clear verbs, snake_case, and a prefix when several areas share a word (github_search, wiki_search).
Schemas from functions
Writing JSON Schema by hand for every tool gets tedious and drifts from the code. Most SDKs build it from the function itself: the parameter names, their type hints, which ones have defaults, and the docstring. Python's inspect module gives you all of that.
Show the answer
"Find lines matching a regex in the project. Use it to locate where a name is defined or used. Returns up to 50 path:line matches."
Say what it does, when to use it, and what it returns. The model sees nothing but the name, description and schema.
A schema from a signature
Type hints become JSON types, and parameters without defaults become required.
import inspect, json JSON_TYPES = {int: "integer", float: "number", str: "string", bool: "boolean", list: "array", dict: "object"} def schema_for(fn): props, required = {}, [] for name, p in inspect.signature(fn).parameters.items(): props[name] = {"type": JSON_TYPES[p.annotation]} if p.default is inspect.Parameter.empty: required.append(name) doc = inspect.getdoc(fn) or "" return {"name": fn.__name__, "description": doc.split("\n")[0], "input_schema": {"type": "object", "properties": props, "required": required}} def read_file(path: str, offset: int = 0, limit: int = 200): """Read lines from a text file in the project, starting at offset.""" print(json.dumps(schema_for(read_file), indent=2))
This is the idea behind decorators like the Python SDK's @beta_tool. Real versions also read per-parameter descriptions from the docstring and handle optional and list types.
Show the answer
Models sometimes send wrong or missing arguments, and a clear error lets them correct the call
A schema guides the model but doesn't always bind it. Checking first turns a crash into a helpful message.
Errors are messages to the model
When a call is wrong, the error goes back as a tool_result with is_error: true, and the model reads it. So write errors the way you'd write them for a colleague: say what was wrong and what to do instead.
| unhelpful | helpful |
|---|---|
KeyError: 'sku' | Missing required parameter: sku (the product code, like TENT-2). |
Invalid input | Parameter limit should be integer, got string "fifty". |
File not found | No file src/util.py. Did you mean src/utils.py? Use glob to list files. |
Don't leak internals in errors: no stack traces, secrets or file paths outside the project.
In plain words: an error is the model's only clue about what to try next, so make it a useful clue.
Write errors the way you'd write to a colleague.
Output budgets
Asking for one fact and getting a whole filing cabinet doesn't help anyone. A tool that returns a 2 MB log would fill the context window in one call. The registry should cap every result, say a few thousand characters, and tell the model it was cut:
...first 4000 characters... [Output truncated: showing 4000 of 182,311 characters. Narrow the search or use offset to read more.]
The note matters. It's like a book with pages torn out: without a note saying so, the model thinks it saw everything. Good tools also offer ways to ask for less: offset and limit for reads, a match cap for searches, filters for lists.
Keep the head and the tail
A test run can print thousands of lines, and the failure is usually near the end. Cutting from the middle keeps both the start and the part you need.
def cap(text, limit=120): if len(text) <= limit: return text keep = limit // 2 cut = len(text) - 2 * keep return f"{text[:keep]}\n[... {cut} characters cut ...]\n{text[-keep:]}" log = "\n".join(f"test_{n} passed" for n in range(500)) + "\ntest_500 FAILED: expected 3, got 4" print("before:", len(log), "characters") capped = cap(log) print(capped) print("kept the failure:", "FAILED" in capped)
Telling the model how much was cut matters too. It can then ask for more, instead of believing it has seen everything. A cap that only keeps the first part would have lost the one line that mattered.
output = "x" * 5000 limit = 2000 print(len(output[:limit]))
Show the answer
2000
output[:limit] keeps the first 2000 characters, so len gives 2000. Capping tool output protects the context window from one huge result.
When there are many tools
Every definition costs input tokens on every call, and long tool lists make choosing harder. Keep the set small and task-shaped.
If you need hundreds, think of a book's index: you look something up when you need it, instead of reading every page first. The Claude API's tool search tool lets you mark rarely used tools defer_loading: true. The model then searches for them when it needs them, and only those definitions load. Keep the common tools loaded; deferring everything would hide them all.
Searching tools instead of sending them all
Every tool definition is sent on every call. With many tools that's a real cost, so a harness can send a short list and let the model search for the rest.
tools = [{"name": f"tool_{n}", "description": f"Does job number {n} for the shop.", "tokens": 300} for n in range(60)]
tools[41] = {"name": "refund_order", "description": "Refund a customer's order.", "tokens": 300}
def search(query, tools):
return [t for t in tools if query in t["description"].lower()]
everything = sum(t["tokens"] for t in tools)
found = search("refund", tools)
print("send all 60:", everything, "tokens on every call")
print("search first:", [t["name"] for t in found], "for", sum(t["tokens"] for t in found), "tokens")That's the difference between 18,000 tokens on every turn and a few hundred. A long list also makes the model choose worse, so fewer tools can help quality as well as cost.
Show the answer
Otherwise the model assumes it saw everything and may miss what it needs
The note tells the model to narrow its request or page through the rest.
A schema is not a guarantee
A form can say "numbers only", and people still write letters in the box. It's easy to assume that if you gave the model a schema, its input will match. Usually it will. But the schema is guidance for the model, and your code must still check what it receives. A model can send a string where you asked for a number, leave out a required field, or invent one you never mentioned. Validate every call before you run it, and send problems back as tool errors.
Don't hide what you cut
If a tool's output is truncated with no notice, the model believes it saw everything. It may then say "there are no failing tests" because the failures were on the part you cut. Always say what was removed and how to get more: "showing lines 1 to 200 of 4,180, use offset to read further."
One tool per task, not per endpoint
A good office has one help desk, so you don't have to visit three windows to get one answer. Wrapping each API endpoint as a tool is quick, and it's rarely the best design. The model has to learn your API's structure, make several calls to do one thing, and hold all the intermediate data in its context. A single tool built around the task, such as find_order_status(email), does the joining in code, which is cheaper and more reliable. As a rule, if you find the model always calling the same three tools in a row, make them one.
Practice what you just read
- 3 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
What does a model know about a tool?