Why this design
Before this design, the natural path was to write a CLI command that contained the logic, then separately write an agent tool that duplicated it, and later write an HTTP endpoint that duplicated it again. Each copy drifts independently. A bug fix in one doesn’t reach the others. A new parameter requires three changes. The specific problems this design resolves: Logic duplication across callers. The same operation (e.g. generate a pairing code) would need to be implemented three times: once incommands/device.py, once in the LangChain tool definition, and once in an HTTP handler. Any change to the operation requires finding and updating all three.
No single enforcement point. With three separate implementations, there is no single place to add policy checks, audit logging, or rate limiting. Each caller has to remember to apply them independently — and they won’t.
Inconsistent schemas. The CLI arg names, the agent tool parameter names, and the HTTP request fields would all drift apart over time, making the system harder to reason about.
The design resolves these by:
- Defining each operation as a
Toolclass with a singleexecute()method - Making CLI, agent, and HTTP all call
execute()— they own rendering and transport, not logic - Providing a
ToolRegistryas the single dispatch point for HTTP and future callers, where cross-cutting concerns live
Architecture overview
The Tool abstraction
Every tool is a class that inherits fromTool (tools/base.py) and declares three things:
name — used by the registry to dispatch /invoke calls, and by the agent to identify the tool.
description — shown to the LLM when it decides which tool to call. Write it for an AI reader, not a human.
params — flat dict of ToolParam. This is the single source of truth for CLI argument specs, LLM tool schemas, and the /invoke request shape. All three are derived from it automatically — you declare params once.
execute() — pure logic. No console.print, no typer.Exit, no HTTP. Returns a typed dataclass result. Raises a typed exception on failure.
Result types
Each tool returns a dedicated dataclass:dataclasses.asdict()).
Caller 1 — CLI
CLI commands (commands/device.py) are thin wrappers. They parse flags, call execute(), and render the result using rich. They contain no business logic.
setup, start, and stop.
Caller 2 — AI agent
The agent (agent_manager.py) uses LangChain. The langchain_adapter.py module converts any Tool into a StructuredTool automatically, using the params dict to build the Pydantic schema:
tool.params to generate the LLM-facing schema, and wraps tool.execute() as the function LangChain calls when the model selects the tool. You never write a separate LangChain tool definition — the Tool class is the definition.
The agent runs inside the server process, so it has direct in-process access to all tool instances. No HTTP boundary.
Caller 3 — HTTP API (/invoke)
The ToolRegistry (tools/registry.py) holds one instance of every registered tool and dispatches calls by name. It is the entry point for any caller that cannot import Python directly — primarily the web management UI.
Registry setup
At server startup (_server_process.py), the registry is built and injected into the FastAPI server:
Endpoints
GET /tools — returns the schema of every registered tool. The web UI calls this to discover what operations are available and what parameters each one accepts. No hardcoding required.
POST /invoke — executes a tool by name with a flat params dict:
params to only the keys the tool declares, so callers don’t need to be perfectly in sync with the tool signature. Unknown keys are silently ignored.
Error responses:
| Situation | HTTP status | Detail |
|---|---|---|
| Tool name not registered | 404 | "Unknown tool: 'foo'. Available: [...]" |
execute() raised an exception | 500 | "Tool 'device_add' raised: ..." |
| Registry not yet initialised | 503 | "Tool registry not initialised" |
Policy function (future)
The registry accepts an optionalpolicy callable. When provided, it is called before every invoke() dispatch, regardless of which caller triggered it. Raising any exception from the policy blocks the call.
context dict is populated by the caller — the HTTP endpoint injects the authenticated user and role, the agent injects its identity. The tools themselves never see or handle policy; it is entirely the registry’s concern.
Planned policy capabilities:
- Source-based rules — restrict which tools the agent can call vs. a human via web UI
- Role-based access — read-only roles can call
listtools but notaddorrevoke - Destructive op confirmation — require an explicit
confirmed: trueparam for irreversible operations - Rate limiting — block repeated calls to
device_addwithin a time window - Audit logging — record every tool call with caller identity and result
How to add a new tool
Follow these steps every time you add a new operation. The pattern is identical regardless of what the operation does.Step 1 — Define the tool class
Create or add to a file inhirocli/tools/. Group related tools in one file (e.g. all channel operations in tools/channel.py).
execute():
- No
console.printortyper.Exit - No
sys.exit - Return a dataclass result
- Raise a typed exception on failure (e.g.
WorkspaceError,ValueError)
Step 2 — Export from tools/__init__.py and add to all_tools()
Add the import at the top of tools/__init__.py:
all_tools() list:
Step 3 — Add the CLI command
Step 5 — Add the CLI command
Incommands/channel.py (or a new commands file), write a thin wrapper:
execute() call, result rendering.
Summary checklist
| Step | File | What you add |
|---|---|---|
| 1 | tools/<domain>.py | Tool class + result dataclass |
| 2 | tools/__init__.py | Export the class + add instance to all_tools() |
| 3 | commands/<domain>.py | Thin CLI wrapper |
all_tools() feeds both the HTTP registry and the AI agent automatically. Step 3 is rendering only — no logic.
Benefits summary
| Concern | How this design handles it |
|---|---|
| Single source of truth | Logic lives in execute() once. CLI, agent, and HTTP all call it. |
| No schema drift | params dict is the single definition for CLI args, LLM schemas, and HTTP docs. |
| Uniform policy enforcement | ToolRegistry.invoke() is the only dispatch path for HTTP callers. Policy runs once, covers all tools. |
| Easy discoverability | GET /tools exposes the full schema dynamically. Web UI never hardcodes tool names or params. |
| Offline CLI | CLI imports tools directly — no server required for cold operations. |
| In-process agent | Agent calls execute() directly with no HTTP boundary or serialisation overhead. |
| Incremental growth | Adding a tool is 3 mechanical steps. No new HTTP routes, no new adapter code, no new agent plumbing. |
