Index Ai Academy
Phase 10 · Agent harnesses · Tools & environment

A tool registry for agents

Schemas from functions, validation, output limits and helpful errors.

About 35 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. What the model sees
  2. A schema from a signature
  3. Errors are messages to the model
  4. Output budgets
  5. Keep the head and the tail
  6. When there are many tools
  7. Searching tools instead of sending them all
  8. 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:

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

Quick check
Which description will help a model choose this tool correctly?
  1. "grep wrapper v2"
  2. "Searches."
  3. "The best search tool."
  4. "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."
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.

Worked example

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))
It runs right here in your browser, with nothing to install.

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.

Quick check
Why validate tool inputs if the model was given the schema?
  1. Validation makes the model faster
  2. It isn't needed; the schema guarantees correct input
  3. Models sometimes send wrong or missing arguments, and a clear error lets them correct the call
  4. The API refuses tool calls that aren't validated
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.

unhelpfulhelpful
KeyError: 'sku'Missing required parameter: sku (the product code, like TENT-2).
Invalid inputParameter limit should be integer, got string "fifty".
File not foundNo 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.

Try it now

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.

Worked example

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)
In the lesson you can change this and run it.

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.

Quick check
What does this print?
output = "x" * 5000
limit = 2000
print(len(output[:limit]))
  1. 3000
  2. 7000
  3. 2000
  4. 5000
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.

Worked example

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")
In the lesson you can change this and run it.

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.

Quick check
A tool cut its output at 4,000 characters. Why add a note saying it was cut?
  1. It stops the model calling the tool again
  2. Otherwise the model assumes it saw everything and may miss what it needs
  3. The note makes the output shorter
  4. The API requires the note
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

Create a free account

Try a quiz question

What does a model know about a tool?

  1. Its name, description and input schema
  2. Its source code
  3. Its unit tests
  4. Everything in the harness's memory

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson