ChiaTool

ChiaTool is the primitive CHIA uses to allow an AI agent to orchestrate control of the flow. Where chia_remote(...) creates a node that your flow dispatches programmatically — a programmatic edge in the task graph — a ChiaTool stands up an MCP server on a worker that an agent can call on its own initiative. That is an agentic edge: you give the agent a set of tools and a prompt, and the agent decides which tools to call, when, and with what arguments. This page explains the MCP concepts the tool layer leans on, walks through a tool’s lifecycle, shows how tools attach to an agent, and how to define your own tools.

A few MCP concepts

CHIA tools speak the Model Context Protocol (MCP), the open standard agents use to discover and call external tools over HTTP.

  • Tool (method) — a single callable the agent can invoke, with a name and a docstring/type signature. The agent reads those to decide when to call the tool and how to fill in its arguments, so a tool method’s docstring is part of its interface, not just documentation.

  • Tool server — a small HTTP server (FastAPI + uvicorn) that hosts one ChiaTool’s methods and speaks MCP. CHIA deploys it onto a worker as a Ray actor so it persists across many agent calls (see ChiaFunction for the actor/worker vocabulary). Its MCP endpoint is:

    http://{host}:{port}/{name}/mcp
    
  • Resources / placement — like a node, a tool server is pinned onto a worker that advertises the resources it needs. You pass these as task_options (see Tool placement and resources), so the agent’s bash tool lands on, say, the worker that holds a Chipyard checkout.

Anatomy of a tool

CHIA provides a library of ready-made tools and a base class, chia.base.tools.ChiaTool, for writing your own. A tool is a plain Python class that subclasses ChiaTool, bundles the tool’s configuration and state, and registers one or more methods as the callable tools the agent sees.

chia.base.tools.BashTool is a representative example: it gives an agent a bash interface to the worker it is deployed on. The class holds the working directory and registers a single run_command method:

class BashTool(ChiaTool):
   def setup(self, work_dir="/"):
       self.work_dir = work_dir
       self.mcp.add_tool(self.run_command, name=f"{self.name}_run_command")

   def run_command(self, command: str) -> str:
       """Run a bash command and return combined stdout/stderr."""
       ...                                                       # runs in the container

bt = BashTool(name="bash", work_dir="/home/", task_options={"resources": {"chipyard": 1}})

To create a ChiaTool, you create a subclass which defines a setup() method where you register all of the tools with self.mcp.add_tool(...).

The base class runs its own initializer behind the scenes — creating the self.mcp server object your tools attach to — then calls your setup() method to register the tools, then deploys the running server behind the scenes.

The constructor of the child class forwards arguments to the setup call (like work_dir) and takes two additional arguments: name and task_options. The former is a human readable string used by the agent to reference the tool, and the latter is used to determine where to place the tool server, and includes things like resource specifications and placement groups.

Alternatively, if you would rather write the constructor by hand, an explicit initialization idiom is supported and can be used instead of setup().

The tool lifecycle

Constructing a tool deploys it

Unlike a node, which only runs when you call chia_remote, instantiating a ChiaTool immediately stands up its server on a worker: once setup() has registered the tools, the base deploys the server automatically. So construction is the deploy step:

from chia.base.tools.BashTool import BashTool

chipyard_bash = BashTool(
    name = "chipyard_bash", work_dir="/home/ray/chipyard",
    task_options={"resources": {"chipyard": 1}},   # land on a chipyard worker
)
# The MCP server is now live at
# http://{chipyard_bash.hostname}:{chipyard_bash.port}/chipyard_bash/mcp

Attaching a tool to an agent

Pass the tool (or several) to any CHIA provided LLM or agent’s prompt node via the tools argument. The agent receives each tool’s MCP endpoint, discovers the methods, and calls them as needed to satisfy the prompt:

from chia.base.ChiaFunction import get

prompt = (
    "Use the chipyard_bash tool to add a printf to the Rocket core's "
    "Chisel source that fires when instret reaches 1000."
)
result = get(llm.prompt.chia_remote(llm, prompt, tools=[chipyard_bash]))
print(result.result)

Here the agent itself is a node — llm.prompt is dispatched with chia_remote — and the tool it was handed lets it reach out and act on the cluster while it runs. The Quickstart: Say Hello (World) with CHIA builds up exactly this pattern, ending with an agent editing RTL through a BashTool.

Stopping a tool

A tool server stays running until you stop it. Call stop() once the agent work that needs it is done; it shuts uvicorn down and kills the actor, freeing the port and the worker slot:

chipyard_bash.stop()

Bridging tools back to nodes

A tool method is ordinary Python, so it can dispatch @ChiaFunction nodes just like the rest of your flow. Because the agent expects a concrete return value (not an ObjectRef), use chia_remote_blocking, which dispatches remotely and returns the unwrapped result.

This is a composition point between the two primitives: the agent makes one tool call (an agentic edge), and that call fans out real, scheduled cluster work (programmatic edges) before returning a value the agent can reason about.

Defining your own tool

Subclass ChiaTool and define a setup() method that registers your tools, giving each method a clear docstring (which is given to the agent to describe the tool) and typed signature — the agent relies on both to call it correctly. chia.base.tools.ChiaToolTemplate is a copyable starting point.

Here’s an example:

from chia.base.tools.ChiaTool import ChiaTool

class GreetingTool(ChiaTool):
    def setup(self, greeting="Hello"):
        self.greeting = greeting
        self.mcp.add_tool(self.greet, name=f"{self.name}_greet")

    def greet(self, who: str) -> str:
        """Return a greeting for *who*.

        Args:
            who: The name to greet.
        """
        return f"{self.greeting}, {who}! This is {self.name}."

A tool method may return any type — MCP always serializes the result for the agent, falling back to a JSON (then str) rendering of arbitrary objects. Annotating the method’s return type (as greet does with -> str) can allow MCP to provide the agent a schema for the return value in some cases. The following return types are formatted more cleanly and are preferred:

  • str — handed to the agent as plain text.

  • dict, a pydantic BaseModel, a TypedDict, or a dataclass — returned as structured, machine-readable JSON.

  • list / tuple.

  • None — an empty result.

  • MCP Image / Audio content objects (from mcp.server.fastmcp) — for image or audio payloads, instead of raw bytes.

For tools that wrap long-running work, CHIA also provides asynchronous variants (chia.base.tools.AsyncBashTool, chia.base.tools.AsyncJobTool) that split a job into a submit method and a status method so the agent can poll rather than block.

Tool placement and resources

task_options is the dict of Ray scheduling constraints applied to the tool’s server actor — the tool analog of a node’s per-call .options(...). The most common entry is resources, which pins the server onto a worker advertising the right capability:

# Land the bash tool on a worker that has a chipyard checkout.
BashTool("chipyard_bash", "/home/ray/chipyard",
         task_options={"resources": {"chipyard": 1}})

Because the server runs on that worker, anything the tool’s methods do — run a command, read a file, dispatch a node — happens with that worker’s filesystem and resources in reach. Omitting task_options lets the server land on any available worker.

Advanced

The explicit initialization idiom

Before the setup() hook existed, every tool was written with an explicit __init__ that called the base class’s construction steps by hand. This style is still fully supported: define __init__ instead of setup() and CHIA leaves it untouched. You might prefer it when you want the tool’s constructor signature to be explicit (for introspection or IDE help), or to match tools already written this way.

An __init__ written this way follows a fixed three-step order, and the order matters:

  1. ``super().__init__(name, task_options=…)`` creates the FastMCP instance (self.mcp) the tools attach to. It does not start a server yet.

  2. ``self.mcp.add_tool(self.method, name=…)`` registers each method as an MCP tool. This must happen after step 1 (so self.mcp exists) and before step 3 (so the server has its tools when it starts serving). Call it once per method you want to expose.

  3. ``super().__post_init__()`` deploys the server: it creates a Ray actor (constrained by task_options), which probes for a free port, starts uvicorn in a background thread, and registers the running tool. After this returns, self.hostname and self.port point at the live endpoint.

The same BashTool written explicitly:

class BashTool(ChiaTool):
    def __init__(self, name, work_dir="/", task_options=None):
        super().__init__(name, task_options=task_options)               # 1. init base
        self.work_dir = work_dir
        self.mcp.add_tool(self.run_command, name=f"{name}_run_command")  # 2. register
        super().__post_init__()                                         # 3. deploy

    def run_command(self, command: str) -> str:
        """Run a bash command and return combined stdout/stderr."""
        ...

The two styles are interchangeable and can coexist in the same codebase: a subclass that defines setup() gets the automatic bracketing, while one that defines __init__ keeps full manual control.

Intermediate base classes

An intermediate base sits between ChiaTool and a concrete tool, bundling functionality that several tools share — chia.base.tools.AsyncJobTool (the base for the async variants above) is the canonical example, adding a background-job runner its subclasses inherit.

Writing one correctly hinges on a single fact: the setup() idiom’s generated __init__ calls ChiaTool.__init__ directly, skipping every intermediate __init__ in between. So a setup()-style subclass of your intermediate never runs your intermediate’s __init__. Make the intermediate work whether or not its own __init__ runs:

Initialize state lazily, not in** __init__. E.g. provide an idempotent _ensure_X() that creates the state on first use, and call it at the top of every helper that needs it. This is the rule that makes the class work under both idioms with no per-subclass boilerplate:

def _ensure_job_state(self):
    if not hasattr(self, "_job_lock"):
        self._init_job_state()

def _job_start(self, work):
    self._ensure_job_state()      # works whether or not __init__ ran
    ...

This covers state that can be created lazily with sensible defaults. It does not transparently cover eager work that depends on constructor arguments (e.g. opening a connection from a dsn= passed in). For that, you may requier the leaf’s setup() pass the arguments into the super class explicitly.

See also