Tool Registry with Schema Validation
A tool the agent can't validate is a tool the agent can't call. Build the registry and the schema checker before the tools.
Learning Objectives
- Hold a typed registry of tool name → schema → handler that the dispatcher can ask once and trust afterwards.
- Implement a JSON Schema 2020-12 subset that covers the keywords ninety percent of tool calls actually use.
- Return precise, json-pointer-shaped error paths so the model can self-correct in one round trip.
- Reject re-registration without explicit override, since silent overwrites are how production tool catalogs drift.
- Keep the validator pure (no I/O, no time, no globals) so it can be re-run on a replay log.
Why the registry comes before the tool
The tool registry is the single table that answers three questions: what tools exist, what shape each tool's arguments take, and which handler to call. Once those three answers are pinned down, the rest of the harness can stop guessing.
It matters more than it sounds. A coding agent in 2026 has more registered tools than fit in one context window. A non-trivial harness registers two hundred and surfaces ten to forty on any given turn, so something has to own the full catalog.
The mistake to avoid is shipping handlers without schemas, or schemas without validation. Both are common. Both turn the next layer (the dispatcher in lesson twenty-three) into a guessing game where the only failure you ever see is a stack trace from inside a handler.
What a tool record looks like
A tool record is the unit the registry stores: one name, one schema, one handler, plus the metadata the dispatcher needs.
ToolRecord
name : str (unique, lowercase alphanumeric and underscore segments separated by dots, e.g., snake_case.segment.case)
description : str (one line, shown to the model)
schema : dict (JSON Schema 2020-12 subset)
handler : Callable (async or sync, returns Any)
idempotent : bool (dispatcher uses this for retry decisions)
timeout_ms : int (override per-tool dispatcher default)The validator touches the schema and nothing else. The handler is opaque to it. That split is deliberate: the schema is data and the handler is code. Mix them and you will be tempted to put validation logic inside the handler, which is exactly the bug this lesson exists to stop.
The JSON Schema 2020-12 subset
JSON Schema is a standard format for describing the shape of a JSON value. The full 2020-12 spec is a paper, and a tool registry needs eight of its keywords.
type string / number / integer / boolean / object / array / null
properties map of property name -> schema
required list of property names
enum list of allowed primitive values
minLength integer, applies to strings
maxLength integer, applies to strings
pattern ECMA-262-compatible regex, applies to strings
items schema applied to every array elementThat covers what a tool API actually needs. The keywords left out (oneOf, anyOf, allOf, $ref, conditionals) are valid in production schemas, but they turn the validator into a tree walker with cycles. This is a registry, not a JSON Schema engine.
Json pointer error paths
A json pointer is a slash-prefixed sequence of property names and array indices that locates one value inside a JSON document. When validation fails, the validator returns a list of errors, and each error carries a pointer into the input.
{"a": {"b": [1, 2, "x"]}}
^
/a/b/2The model reads error paths better than it reads sentences. Say a schema requires args.user.email and the model passed an integer. The error should carry the path /user/email and a message saying it expected a string, and the model can fix that on the next call without a round of natural language.
Registration and override
register(name, schema, handler, **opts) adds a tool to the registry and rejects re-registration by default. To replace an existing tool, the caller has to pass override=True. Think of it as operational hygiene: two parts of the codebase silently registering the same tool name is the kind of bug that takes a week to find in production.
Reads go through three methods. get(name) returns the record or raises. validate(name, args) returns an Ok or a list of errors. names() returns the tool names in registration order.
What the validator is and is not
The validator is a single recursive pass over the schema tree. It is pure. It does not call handlers, it does not coerce types (a string "42" does not pass a number schema), and it does not silently truncate.
It is also not a security boundary. A malicious handler can still misbehave after validation passes. The dispatcher in lesson twenty-three adds the timeout and sandbox layers. The registry adds shape.
Shape
I'd keep the schema and the handler in separate fields even for a tool small enough to validate inline. The record above does it on purpose, and I'd rather pay that small cost up front than find validation logic scattered across handlers later.
My bet is that the eight-keyword subset holds up longer than you'd expect, so I'd resist adding oneOf or anyOf until a real tool forces the issue. If you do extend it, I'd start with $ref and additionalProperties: false, the two additions from Going further, and keep the validator pure while you do it. Purity is what lets you re-run it on a replay log.
How to read the code
code/main.py defines ToolRegistry, ToolRecord, ValidationError, and the validator functions (_walk plus the per-type checks _check_string, _check_object, and _check_array). The validator dispatches on schema["type"], or treats a schema with enum as an untyped enum check. Each type check appends ValidationError entries to a shared list, and _walk extends the path with one segment as it descends.
code/tests/test_registry.py covers registration, override, validation success, validation failure with paths, and every keyword in the subset.
Going further
Two extensions are worth adding once this lesson lands: $ref resolution against a local definitions block, and additionalProperties: false for strict shape. Both are small, and both tend to show up as the tool catalog grows past fifty tools. I left them out to keep the file under one read.
The next lesson (twenty-two) builds the JSON-RPC stdio transport that surfaces this registry to a model client. The one after (twenty-three) wraps both behind a dispatcher with timeouts and retries.
Code
- main.py (python, 285 lines)
- tests/test_registry.py (python, 187 lines)