Developer Guide
A “tool” is a capability the language model can call — search docs, fetch a building, send a notification. For how the agent uses tools at runtime, see Agent Service.
The agent loop (app/agent/loop.py) discovers tools through a REGISTRY, validates the model’s JSON arguments through your Pydantic Args model, calls run, and feeds the result back into the conversation. The whole contract is structural: a tool is any object that exposes the right attributes, so you never inherit from a base class. This guide walks through adding one end-to-end.
Before writing code, answer four questions:
description the model reads.Args Pydantic model.The twin tools (app/agent/tools/twin.py) are the reference pattern for a sensitive tool: each one calls can_access_domain(ctx.user, args.domain) before touching data, and forwards the caller’s identity downstream via auth_headers(ctx.user) rather than calling twin-service with no identity at all. Follow the same shape for any new tool that reads or writes real data — authorize against ctx.user first, and forward it to whatever backend the tool calls.
app/agent/tools/<name>.py.app/agent/tools/twin.py — four tools sharing the same twin-service backend and client factory).Use # ─── tool_name ──... divider comments inside multi-tool files to keep them scannable.
Args Modelfrom pydantic import BaseModel, Field
class GetRoomArgs(BaseModel):
building_id: str = Field(description="Building containing the room.")
room_id: str = Field(description="Id of the room within the building.")The reason every field is a Pydantic field with a description is that the agent turns this class into a JSON Schema (Args.model_json_schema()) and hands it to the model — so this class is the model-facing contract, not just internal validation.
Field(description=...). The model sees the complete generated JSON Schema — argument names, types, required fields, defaults, enums, constraints, and descriptions. Descriptions explain the semantics those structural fields cannot express, so vague descriptions still cause vague tool calls.str, int, bool, Literal[...], enums). Pydantic enforces it and the model sees it in the schema.Field(default=5, ge=1, le=10) (see search_docs.py).building_id, not bldgIdentifier — the model produces these names from the schema.from app.agent.tools.base import ToolContext, ToolResult
class GetRoomTool:
name = "get_room"
description = (
"Fetch a single room's full state: capacity, current occupancy, temperature, "
"dimensions, position. Use after locating the room id via list_rooms."
)
Args = GetRoomArgs
async def run(self, args: GetRoomArgs, ctx: ToolContext) -> ToolResult:
...| Attribute | Purpose |
|---|---|
name | Stable identifier the model emits in tool_calls. Snake_case, unique. |
description | What the tool does and when to use it versus alternatives. See below. |
Args | The Pydantic class from step 3; the registry exposes it via model_json_schema(). |
run | Async method, signature (self, args: <YourArgs>, ctx: ToolContext) -> ToolResult. |
descriptionThe model uses this to choose between tools, so state what it does and when to pick it over neighbors. Compare:
list_rooms.” — names the precondition and steers the model away from calling with a guessed id.For a tool with a sibling that does something close (search_docs versus the twin tools), explicitly say what it is not for — see SearchDocsTool in search_docs.py.
Tool in app/agent/tools/base.py is a Protocol[ArgsT] — generic over your Args type. You do not inherit from it: Python’s structural typing means that as long as your class has name, description, Args, and a matching run, it already satisfies Tool[YourArgs]. The type variable is what lets the type checker see that run(args: GetRoomArgs, ...) is a valid implementation rather than a signature violation.
Do not widen your run parameter to BaseModel to “match the protocol” — that defeats the point and loses type safety. Keep it narrow.
runasync def run(self, args: GetRoomArgs, ctx: ToolContext) -> ToolResult:
async with _client() as c:
r = await c.get(f"/building/{args.building_id}")
if r.status_code == 404:
return ToolResult(content=f"building {args.building_id} not found", is_error=True)
if r.status_code >= 400:
return ToolResult(content=f"twin-service error {r.status_code}: {r.text}", is_error=True)
...
return ToolResult(content=room)ToolResult(content=<message>, is_error=True). The loop catches uncaught exceptions anyway, but a clean is_error message is sent back to the model so it can recover (try a different id, ask the user). Raising is for genuine bugs.ctx: ctx.session is an async SQLAlchemy session; ctx.user is the authenticated AuthUser (use it to enforce per-tool access control before touching sensitive data); ctx.citations accumulates citation candidates — prefer returning new ones via ToolResult(citations=[...]), which the loop appends (see SearchDocsTool).structlog: from app.logging import get_logger; log = get_logger(__name__).httpx.AsyncClient, a retriever) are fine; per-call state belongs in ctx or local variables.Open app/agent/tools/__init__.py and add two lines:
from app.agent.tools.twin import GetRoomTool
...
REGISTRY.register(GetRoomTool())Order does not matter. Duplicate names raise at startup — that is intentional, to catch copy-paste collisions early.
Three layers, lightest first:
tests/unit/. Skip it for trivial fields.run with a fake backend — inject a fake frontend/session and assert on the ToolResult. Mock the boundary (httpx, the SQLAlchemy session), not the tool itself.LLMClient yet, so for now test run at the backend boundary and smoke-test dispatch through /ask. When a reusable fake model is added, assert the full tool-call and recovery sequence through Agent.answer.npm run lint # ruff + pyright, must be clean
npm run test # unit + integrationThe checks catch many common mistakes, including invalid return annotations and ordinary type errors inside run. The generic Tool[ArgsT] protocol documents the intended relationship between Args and the run parameter, but registration currently stores tools as Tool[Any]; do not rely on Pyright alone to catch every mismatch between those two attributes. Exercise schema generation and dispatch in tests for non-trivial tools.
app/agent/tools/<name>.py (or grouped sensibly with siblings).Args is a BaseModel; every field has a Field(description=...).name (snake_case, unique), description (with when to use), Args, async run.run(args: <YourArgs>, ctx: ToolContext) -> ToolResult — narrow types, no widening to BaseModel.ToolResult(..., is_error=True), not raised.ctx.user; write tools have confirmation and audit behavior.app/agent/tools/__init__.py.npm run lint and npm run test pass.