/

Developer Guide

Adding an Agent Tool

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.


1. Pick The Shape

Before writing code, answer four questions:

  1. What does the tool do, in one sentence? This becomes the description the model reads.
  2. What inputs does it need? Each input becomes a field on the Args Pydantic model.
  3. What does it return? Keep payloads small — the result is fed back into the model’s context as JSON, where it costs tokens and can distract the model. Project away fields it does not need.
  4. Read-only or write? The current agent has no confirmation flow for write actions. Keep tools read-only unless authorization, confirmation, audit logging, and retry behavior are explicitly designed.

Note

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.


2. Where The File Lives

Use # ─── tool_name ──... divider comments inside multi-tool files to keep them scannable.


3. Write The Args Model

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


4. Write The Tool Class

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:
        ...
AttributePurpose
nameStable identifier the model emits in tool_calls. Snake_case, unique.
descriptionWhat the tool does and when to use it versus alternatives. See below.
ArgsThe Pydantic class from step 3; the registry exposes it via model_json_schema().
runAsync method, signature (self, args: <YourArgs>, ctx: ToolContext) -> ToolResult.

Writing A Good description

The model uses this to choose between tools, so state what it does and when to pick it over neighbors. Compare:

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.

About The Generic Protocol

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.

Note

Do not widen your run parameter to BaseModel to “match the protocol” — that defeats the point and loses type safety. Keep it narrow.


5. Implement run

async 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)

6. Register The Tool

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.


7. Test It

Three layers, lightest first:


8. Run The Checks

npm run lint       # ruff + pyright, must be clean
npm run test       # unit + integration

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


Quick Checklist