> ## Documentation Index
> Fetch the complete documentation index at: https://docs.hiroleague.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Tools architecture

> How Hiro League unifies CLI commands, AI agent tools, and web API calls through a single Tool abstraction.

export const DiagramViewer = ({children, title = "Diagram"}) => {
  const dialogRef = useRef(null);
  const viewportRef = useRef(null);
  const contentRef = useRef(null);
  const [isOpen, setIsOpen] = useState(false);
  const [zoom, setZoom] = useState(1);
  const [pan, setPan] = useState({
    x: 0,
    y: 0
  });
  const zoomRef = useRef(1);
  const panRef = useRef({
    x: 0,
    y: 0
  });
  const dragRef = useRef(null);
  const open = () => {
    setZoom(1);
    setPan({
      x: 0,
      y: 0
    });
    setIsOpen(true);
  };
  const close = () => {
    setZoom(1);
    setPan({
      x: 0,
      y: 0
    });
    setIsOpen(false);
  };
  const resetView = () => {
    fitDiagramToViewport();
    setPan({
      x: 0,
      y: 0
    });
  };
  const setView = (nextZoom, nextPan) => {
    zoomRef.current = nextZoom;
    panRef.current = nextPan;
    setZoom(nextZoom);
    setPan(nextPan);
  };
  const MaximizeIcon = () => <svg aria-hidden="true" className="diagram-viewer__icon" viewBox="0 0 24 24">
      <path d="M8 3H3v5M16 3h5v5M8 21H3v-5M16 21h5v-5" />
    </svg>;
  const ResetIcon = () => <svg aria-hidden="true" className="diagram-viewer__icon" viewBox="0 0 24 24">
      <path d="M3 12a9 9 0 1 0 3-6.7L3 8" />
      <path d="M3 3v5h5" />
    </svg>;
  const CloseIcon = () => <svg aria-hidden="true" className="diagram-viewer__icon" viewBox="0 0 24 24">
      <path d="M18 6 6 18M6 6l12 12" />
    </svg>;
  const fitDiagramToViewport = () => {
    const viewport = viewportRef.current;
    const content = contentRef.current;
    const svg = content?.querySelector(".mermaid svg[viewBox]");
    if (!viewport || !svg) return;
    const viewBox = svg.getAttribute("viewBox")?.split(/\s+/).map(Number);
    if (!viewBox || viewBox.length !== 4 || !viewBox[2] || !viewBox[3]) return;
    const [, , width, height] = viewBox;
    svg.style.width = `${width}px`;
    svg.style.maxWidth = "none";
    const rect = viewport.getBoundingClientRect();
    const nextZoom = Math.min(2.2, Math.max(0.75, Number((Math.min((rect.width - 96) / width, (rect.height - 96) / height) * 0.98).toFixed(3))));
    setView(nextZoom, {
      x: 0,
      y: 0
    });
  };
  const handleWheel = event => {
    if (!isOpen) return;
    event.preventDefault();
    event.stopPropagation();
    const viewport = viewportRef.current;
    if (!viewport) return;
    const rect = viewport.getBoundingClientRect();
    const pointerX = event.clientX - rect.left - rect.width / 2;
    const pointerY = event.clientY - rect.top - rect.height / 2;
    const currentZoom = zoomRef.current;
    const currentPan = panRef.current;
    const nextZoom = Math.max(0.25, Math.min(5, Number((currentZoom * (event.deltaY < 0 ? 1.12 : 0.88)).toFixed(3))));
    const ratio = nextZoom / currentZoom;
    setView(nextZoom, {
      x: pointerX - (pointerX - currentPan.x) * ratio,
      y: pointerY - (pointerY - currentPan.y) * ratio
    });
  };
  const onPointerDown = event => {
    if (!isOpen || event.button !== 0) return;
    event.preventDefault();
    dragRef.current = {
      pointerId: event.pointerId,
      startX: event.clientX,
      startY: event.clientY,
      pan: panRef.current
    };
    try {
      event.currentTarget.setPointerCapture?.(event.pointerId);
    } catch {}
  };
  const onPointerMove = event => {
    const drag = dragRef.current;
    if (!isOpen || !drag || drag.pointerId !== event.pointerId) return;
    event.preventDefault();
    const nextPan = {
      x: drag.pan.x + event.clientX - drag.startX,
      y: drag.pan.y + event.clientY - drag.startY
    };
    panRef.current = nextPan;
    setPan(nextPan);
  };
  const endDrag = event => {
    if (dragRef.current?.pointerId === event.pointerId) {
      dragRef.current = null;
    }
  };
  useEffect(() => {
    const dialog = dialogRef.current;
    if (isOpen && dialog && !dialog.open) {
      dialog.showModal();
      requestAnimationFrame(() => {
        requestAnimationFrame(fitDiagramToViewport);
      });
    }
    if (!isOpen && dialog?.open) {
      dialog.close();
    }
  }, [isOpen]);
  useEffect(() => {
    if (!isOpen) return;
    const onKeyDown = event => {
      if (event.key === "Escape") close();
    };
    const onWheel = event => {
      event.preventDefault();
      event.stopPropagation();
      if (viewportRef.current?.contains(event.target)) {
        handleWheel(event);
      }
    };
    const previousOverflow = document.body.style.overflow;
    const previousHtmlOverflow = document.documentElement.style.overflow;
    document.body.style.overflow = "hidden";
    document.documentElement.style.overflow = "hidden";
    window.addEventListener("keydown", onKeyDown);
    window.addEventListener("wheel", onWheel, {
      capture: true,
      passive: false
    });
    return () => {
      document.body.style.overflow = previousOverflow;
      document.documentElement.style.overflow = previousHtmlOverflow;
      window.removeEventListener("keydown", onKeyDown);
      window.removeEventListener("wheel", onWheel, {
        capture: true
      });
    };
  }, [isOpen]);
  const toolbar = <div className="diagram-viewer__bar">
      <span className="diagram-viewer__title">{title}</span>
      <div className="diagram-viewer__actions">
        {isOpen && <span className="diagram-viewer__zoom" aria-live="polite">
            {Math.round(zoom * 100)}%
          </span>}
        {isOpen && <button className="diagram-viewer__button" type="button" onClick={resetView} aria-label="Reset diagram">
            <ResetIcon />
          </button>}
        <button className="diagram-viewer__button" type="button" onClick={isOpen ? close : open} aria-label={isOpen ? "Close" : "Maximize"}>
          {isOpen ? <CloseIcon /> : <MaximizeIcon />}
        </button>
      </div>
    </div>;
  const diagram = <div ref={viewportRef} className="diagram-viewer__viewport" onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={endDrag} onPointerCancel={endDrag}>
      <div className="diagram-viewer__stage" style={{
    transform: isOpen ? `translate(${pan.x}px, ${pan.y}px)` : undefined
  }}>
        <div className="diagram-viewer__centerer">
          <div ref={contentRef} className="diagram-viewer__content" style={{
    transform: isOpen ? `scale(${zoom})` : "none"
  }}>
            {children}
          </div>
        </div>
      </div>
    </div>;
  return <>
      {!isOpen && <div className="diagram-viewer not-prose">
          {toolbar}
          {diagram}
        </div>}

      <dialog ref={dialogRef} className="diagram-viewer-dialog" onWheel={event => {
    if (event.target === event.currentTarget) {
      event.preventDefault();
      event.stopPropagation();
    }
  }} onCancel={event => {
    event.preventDefault();
    close();
  }} onClose={() => setIsOpen(false)}>
        {isOpen && <div className="diagram-viewer diagram-viewer--open not-prose">
            {toolbar}
            {diagram}
          </div>}
      </dialog>
    </>;
};

Every operation in Hiro League is defined once as a **Tool**. The CLI, the AI agent, and the HTTP API are thin callers that invoke the same operation. There is no separate CLI version, agent version, and API version of a feature.

For implementation steps and code examples, see [Tool implementation guide](/build/contribution/tool-implementation-guide).

## Why this design exists

Without a shared tool layer, one operation can turn into three separate implementations:

* A CLI command for terminal users
* An agent tool for LLM-driven actions
* An HTTP endpoint for the web UI and external clients

Those copies drift over time. A bug fix in one caller does not automatically reach the others. Policy checks, audit logging, and parameter schemas also become inconsistent.

The tool architecture makes the operation itself the single source of truth. Callers own transport, rendering, and user experience. Tools own behavior.

## Architecture overview

<DiagramViewer title="Tool caller boundaries">
  ```mermaid actions={false} theme={null}
  flowchart LR
      subgraph Callers["Callers"]
          CLI["CLI"]
          Agent["AI agent"]
          HTTP["HTTP API"]
      end

      Registry["Tool Registry"]
      Tools["Tool classes"]
      Policy["Policy and audit layer"]

      CLI --> Tools
      Agent --> Tools
      HTTP --> Registry
      Registry --> Policy
      Registry --> Tools
  ```
</DiagramViewer>

The **Tool** is the unit of functionality. CLI commands and the AI agent can call tools in process. HTTP callers go through the **Tool Registry**, which gives the server one dispatch point for browser and API requests.

## Responsibilities

| Layer         | Owns                                         | Does not own                                     |
| ------------- | -------------------------------------------- | ------------------------------------------------ |
| Tool          | Operation behavior, validation, typed result | Console rendering, HTTP response shape, UI state |
| CLI           | Argument parsing and terminal output         | Business logic                                   |
| AI agent      | Tool selection through the model runtime     | Separate tool implementations                    |
| HTTP API      | Request/response transport                   | Operation-specific logic                         |
| Tool Registry | HTTP dispatch, policy hooks, discoverability | Per-caller UI behavior                           |

## Tool Registry boundary

The Tool Registry is the server-side entry point for callers that cannot import the Python tools directly. It keeps one registered instance of each tool and dispatches requests by name.

This matters because it gives Hiro League a single place to add cross-cutting behavior for HTTP and future remote callers:

* Source-based restrictions
* Role-based access
* Destructive operation confirmation
* Rate limiting
* Audit logging

The tools themselves do not handle caller identity or transport policy. The caller provides context, and the registry decides whether dispatch is allowed.

## Caller model

**CLI** commands run outside the server process. They import tools directly, call them, and render terminal output. This keeps lifecycle operations available even when the server is stopped.

**AI agent** tools run inside the server process. The agent receives tool schemas derived from the same tool definitions and invokes the selected tool in process.

**HTTP API** callers use registry endpoints. The web UI discovers available tools and invokes them through the server, without hardcoding operation-specific routes.

## Benefits summary

| Concern                    | Design answer                                              |
| -------------------------- | ---------------------------------------------------------- |
| Single source of truth     | Operation behavior lives in one tool definition.           |
| Less schema drift          | Tool metadata feeds CLI, agent, and HTTP surfaces.         |
| Uniform policy enforcement | Registry dispatch is the policy boundary for HTTP callers. |
| Discoverability            | The server can expose registered tools dynamically.        |
| Offline CLI                | CLI commands can run without a live server.                |
| In-process agent           | Agent calls avoid an unnecessary HTTP boundary.            |
| Incremental growth         | New operations follow one repeatable registration path.    |
