Skip to content

Recipe: Agent with mesh tools

One process exposes tools as @handle. Another (or the same) exposes an agent as @channel. The loop calls tools over Zenoh via query_once.

Tool node

# tools.py
from istos import Istos
from istos.communication.config import IstosZenohConfig

app = Istos(
    service_name="math",
    config=IstosZenohConfig(mode="client", connect_endpoints=["tcp/router:7447"]),
)

@app.handle("math/add")
async def add(a: int, b: int) -> int:
    """Add two integers."""
    return a + b

@app.handle("math/mul")
async def mul(a: int, b: int) -> int:
    """Multiply two integers."""
    return a * b

if __name__ == "__main__":
    app.run()

Agent node

# agent.py
from istos import Istos, ChannelSession, MeshTool
from istos.agent import OpenAIChatModel, drive_channel
from istos.communication.config import IstosZenohConfig

app = Istos(
    service_name="agent",
    http_port=8080,
    config=IstosZenohConfig(mode="client", connect_endpoints=["tcp/router:7447"]),
)

# Tools live on the math node — same prefixes, this app's query_once.
tools = [
    MeshTool(
        "math/add",
        app=app,
        description="Add two integers",
        parameters={
            "type": "object",
            "properties": {
                "a": {"type": "integer"},
                "b": {"type": "integer"},
            },
            "required": ["a", "b"],
        },
    ),
    MeshTool(
        "math/mul",
        app=app,
        description="Multiply two integers",
        parameters={
            "type": "object",
            "properties": {
                "a": {"type": "integer"},
                "b": {"type": "integer"},
            },
            "required": ["a", "b"],
        },
    ),
]

model = OpenAIChatModel(
    base_url="http://127.0.0.1:1234/v1",
    model="qwen/qwen3.5-9b",
)

@app.channel("agent/chat", ws="/chat", durable=True)
async def chat(s: ChannelSession):
    await drive_channel(
        s, model, tools,
        system="You are a calculator. Prefer tools over mental arithmetic.",
    )

if __name__ == "__main__":
    app.run()

When the tool handlers are on the same process, use tools_from_handlers(app, prefixes=["math/add", "math/mul"]) instead of hand-built MeshTool entries.

Or let the fabric hand you the catalogue

The schemas above are already published by the math node — capability discovery serves them on .istos/capabilities/<service>. tools_from_discovery reads them and builds the same MeshTool list, so the agent node stops duplicating another service's signatures:

from contextlib import asynccontextmanager
from istos import tools_from_discovery

tools: list = []

@asynccontextmanager
async def on_start(app):
    # Needs an open session, so build the catalogue in the lifespan.
    tools[:] = await tools_from_discovery(app, services=["math"])
    yield

app.lifespan = on_start

@app.channel("agent/chat", ws="/chat", durable=True)
async def chat(s: ChannelSession):
    await drive_channel(s, model, tools, system="…")

Only handle entries become tools (a mesh tool is a query_once; streams and channels are not callable that way). Drop services= for the whole fabric, or pass prefixes= to whitelist exact keys. It is a snapshot — call it again to pick up nodes that joined later, and note that a node started with Istos(enable_discovery=False) does not answer.

Try it

# terminal 1 — zenoh router (or multicast peer mode without a router)
# terminal 2
python tools.py
# terminal 3
python agent.py
# WebSocket client (websocat or similar)
websocat ws://127.0.0.1:8080/chat
> {"text": "what is 6 times 7?"}

Frames on the wire look like {"kind":"tool_call",...}, {"kind":"tool_result",...}, then {"kind":"message","content":"..."}. Pass send_events=False to drive_channel if you only want the final string.

Same pattern with FastAPI in front: Agent channel — bridge the browser socket with open_channel("agent/chat") and leave the loop on the agent node.