Skip to content

Agent API

Mesh tool catalogue, model protocol, and the plan → tool → observe loop.

Details: Agent loop.

Agent loop over mesh tools — plan → query_once → observe.

The fabric primitives stay as they are (@channel, @handle, queues). This package is the glue that turns a channel handler into an agent whose tools are other services on Zenoh.

Agent dataclass

One agent in a handoff graph: a model, its tools, a system prompt, and the agents it may transfer to.

name must be usable in a tool name ([A-Za-z0-9_-]); it is sanitized the same way key expressions are. description is surfaced to a router model in the transfer_to_<name> tool so it knows when to hand off.

Source code in src/istos/agent/multi.py
@dataclass
class Agent:
    """One agent in a handoff graph: a model, its tools, a system prompt, and the
    agents it may transfer to.

    ``name`` must be usable in a tool name (``[A-Za-z0-9_-]``); it is sanitized
    the same way key expressions are. ``description`` is surfaced to a router
    model in the ``transfer_to_<name>`` tool so it knows when to hand off.
    """

    name: str
    model: Model
    tools: Sequence[MeshTool] = field(default_factory=list)
    system: Optional[str] = None
    handoffs: Sequence["Agent"] = field(default_factory=list)
    description: str = ""

    @property
    def tool_name(self) -> str:
        return tool_name(self.name)

AgentEvent dataclass

One step the loop emits for the caller to forward (e.g. over a channel).

kind is one of:

  • message — final assistant text for this turn
  • tool_call — model asked to run a mesh tool (name, arguments)
  • tool_result — tool returned (content); error when it raised
  • handoff — active agent transferred to name (multi-agent loop)
  • done — turn finished (no more steps)
Source code in src/istos/agent/loop.py
@dataclass(frozen=True)
class AgentEvent:
    """One step the loop emits for the caller to forward (e.g. over a channel).

    ``kind`` is one of:

    - ``message`` — final assistant text for this turn
    - ``tool_call`` — model asked to run a mesh tool (``name``, ``arguments``)
    - ``tool_result`` — tool returned (``content``); ``error`` when it raised
    - ``handoff`` — active agent transferred to ``name`` (multi-agent loop)
    - ``done`` — turn finished (no more steps)
    """

    kind: str
    content: Any = None
    name: Optional[str] = None
    arguments: Optional[Dict[str, Any]] = None
    tool_call_id: Optional[str] = None
    error: bool = False

MeshTool

One mesh endpoint the agent may call.

Built from a local @handle via :func:tools_from_handlers, or by hand when the tool lives on another node (pass app and the remote prefix)::

MeshTool("math/add", app=app, description="Add two integers",
         parameters={"type": "object", "properties": {
             "a": {"type": "integer"}, "b": {"type": "integer"},
         }, "required": ["a", "b"]})
Source code in src/istos/agent/tools.py
class MeshTool:
    """One mesh endpoint the agent may call.

    Built from a local ``@handle`` via :func:`tools_from_handlers`, or by hand
    when the tool lives on another node (pass ``app`` and the remote prefix)::

        MeshTool("math/add", app=app, description="Add two integers",
                 parameters={"type": "object", "properties": {
                     "a": {"type": "integer"}, "b": {"type": "integer"},
                 }, "required": ["a", "b"]})
    """

    def __init__(
        self,
        prefix: str,
        *,
        app: Any = None,
        name: Optional[str] = None,
        description: str = "",
        parameters: Optional[dict] = None,
        invoke: Optional[Callable[..., Awaitable[Any]]] = None,
    ) -> None:
        if app is None and invoke is None:
            raise ValueError("MeshTool needs an app (for query_once) or an invoke callable")
        self.prefix = prefix
        self.name = name or tool_name(prefix)
        self.description = description or self.name
        self.parameters = parameters or {"type": "object", "properties": {}}
        self._app = app
        self._invoke = invoke

    def openai_schema(self) -> dict:
        """Tool definition in the OpenAI chat-completions shape."""
        return {
            "type": "function",
            "function": {
                "name": self.name,
                "description": self.description,
                "parameters": self.parameters,
            },
        }

    async def call(
        self,
        arguments: dict,
        *,
        token: Optional[Union[bytes, str]] = None,
        timeout_s: float = 5.0,
    ) -> Any:
        """Run the tool. Mesh tools go through ``query_once`` so authorizers run."""
        if self._invoke is not None:
            return await self._invoke(**arguments)
        assert self._app is not None
        return await self._app.query_once(
            self.prefix, token=token, timeout_s=timeout_s, **arguments
        )

call(arguments, *, token=None, timeout_s=5.0) async

Run the tool. Mesh tools go through query_once so authorizers run.

Source code in src/istos/agent/tools.py
async def call(
    self,
    arguments: dict,
    *,
    token: Optional[Union[bytes, str]] = None,
    timeout_s: float = 5.0,
) -> Any:
    """Run the tool. Mesh tools go through ``query_once`` so authorizers run."""
    if self._invoke is not None:
        return await self._invoke(**arguments)
    assert self._app is not None
    return await self._app.query_once(
        self.prefix, token=token, timeout_s=timeout_s, **arguments
    )

openai_schema()

Tool definition in the OpenAI chat-completions shape.

Source code in src/istos/agent/tools.py
def openai_schema(self) -> dict:
    """Tool definition in the OpenAI chat-completions shape."""
    return {
        "type": "function",
        "function": {
            "name": self.name,
            "description": self.description,
            "parameters": self.parameters,
        },
    }

Model

Bases: Protocol

Source code in src/istos/agent/model.py
@runtime_checkable
class Model(Protocol):
    async def complete(
        self,
        messages: List[dict],
        *,
        tools: Optional[List[dict]] = None,
    ) -> ModelReply:
        """Next assistant turn. ``tools`` is the OpenAI tools array, or None."""
        ...

complete(messages, *, tools=None) async

Next assistant turn. tools is the OpenAI tools array, or None.

Source code in src/istos/agent/model.py
async def complete(
    self,
    messages: List[dict],
    *,
    tools: Optional[List[dict]] = None,
) -> ModelReply:
    """Next assistant turn. ``tools`` is the OpenAI tools array, or None."""
    ...

ModelError

Bases: RuntimeError

The model was unreachable, or answered with something unusable.

Source code in src/istos/agent/model.py
class ModelError(RuntimeError):
    """The model was unreachable, or answered with something unusable."""

ModelReply dataclass

What the model returned for one completion turn.

model, finish_reason, and usage ({prompt_tokens, completion_tokens, …}) are optional telemetry an adapter may fill in; the loop puts them on its completion span. A custom model can leave them unset.

Source code in src/istos/agent/model.py
@dataclass
class ModelReply:
    """What the model returned for one completion turn.

    ``model``, ``finish_reason``, and ``usage`` (``{prompt_tokens,
    completion_tokens, …}``) are optional telemetry an adapter may fill in; the
    loop puts them on its completion span. A custom model can leave them unset.
    """

    content: Optional[str] = None
    tool_calls: List[ToolCall] = field(default_factory=list)
    model: Optional[str] = None
    finish_reason: Optional[str] = None
    usage: Optional[Dict[str, Any]] = None

OpenAIChatModel

Thin /v1/chat/completions client (non-streaming, with tool calls).

Uses aiohttp — already an Istos dependency — so no extra install for the common OpenAI-compatible local servers::

model = OpenAIChatModel(
    base_url="http://127.0.0.1:1234/v1",
    model="qwen/qwen3.5-9b",
)
Source code in src/istos/agent/model.py
class OpenAIChatModel:
    """Thin ``/v1/chat/completions`` client (non-streaming, with tool calls).

    Uses aiohttp — already an Istos dependency — so no extra install for the
    common OpenAI-compatible local servers::

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

    def __init__(
        self,
        *,
        base_url: str,
        model: str,
        api_key: Optional[str] = None,
        timeout_s: float = 120.0,
        temperature: float = 0.0,
        max_tokens: Optional[int] = None,
        extra_body: Optional[Dict[str, Any]] = None,
    ) -> None:
        self.base_url = base_url.rstrip("/")
        self.model = model
        self.api_key = api_key
        self.timeout_s = timeout_s
        self.temperature = temperature
        self.max_tokens = max_tokens
        self.extra_body = extra_body or {}

    async def complete(
        self,
        messages: List[dict],
        *,
        tools: Optional[List[dict]] = None,
    ) -> ModelReply:
        body: Dict[str, Any] = {
            "model": self.model,
            "messages": messages,
            "temperature": self.temperature,
            **self.extra_body,
        }
        if self.max_tokens is not None:
            body["max_tokens"] = self.max_tokens
        if tools:
            body["tools"] = tools
            body["tool_choice"] = "auto"

        headers: Dict[str, str] = {"Content-Type": "application/json"}
        if self.api_key:
            headers["Authorization"] = f"Bearer {self.api_key}"

        timeout = aiohttp.ClientTimeout(total=self.timeout_s)
        try:
            async with aiohttp.ClientSession(timeout=timeout) as session:
                async with session.post(
                    f"{self.base_url}/chat/completions",
                    json=body,
                    headers=headers,
                ) as resp:
                    if resp.status != 200:
                        detail = await resp.text()
                        raise ModelError(
                            f"chat/completions returned {resp.status}: {detail[:400]}"
                        )
                    payload = await resp.json()
        except aiohttp.ClientError as exc:
            raise ModelError(
                f"Could not reach model at {self.base_url}: {exc}"
            ) from exc

        try:
            message = payload["choices"][0]["message"]
        except (KeyError, IndexError, TypeError) as exc:
            raise ModelError(
                f"Unexpected response shape: {json.dumps(payload)[:400]}"
            ) from exc

        finish = payload["choices"][0].get("finish_reason")
        usage = payload.get("usage") if isinstance(payload.get("usage"), dict) else None

        content = message.get("content")
        if isinstance(content, str):
            content = content.strip() or None
        else:
            content = None

        tool_calls: List[ToolCall] = []
        for raw in message.get("tool_calls") or []:
            try:
                fn = raw["function"]
                args_raw = fn.get("arguments") or "{}"
                if isinstance(args_raw, str):
                    arguments = json.loads(args_raw) if args_raw.strip() else {}
                elif isinstance(args_raw, dict):
                    arguments = args_raw
                else:
                    arguments = {}
                if not isinstance(arguments, dict):
                    arguments = {}
                tool_calls.append(
                    ToolCall(
                        id=str(raw.get("id") or uuid.uuid4().hex),
                        name=str(fn["name"]),
                        arguments=arguments,
                    )
                )
            except (KeyError, TypeError, json.JSONDecodeError) as exc:
                _logger.warning(
                    "Skipping malformed tool_call from model: %s", exc,
                    extra={"raw": raw},
                )
                continue

        return ModelReply(
            content=content,
            tool_calls=tool_calls,
            model=payload.get("model") or self.model,
            finish_reason=finish,
            usage=usage,
        )

ToolCall dataclass

One function call the model asked for.

Source code in src/istos/agent/model.py
@dataclass
class ToolCall:
    """One function call the model asked for."""

    id: str
    name: str
    arguments: Dict[str, Any]

build_registry(entry)

Every agent reachable from entry through handoffs, keyed by name.

Source code in src/istos/agent/multi.py
def build_registry(entry: Agent) -> Dict[str, Agent]:
    """Every agent reachable from ``entry`` through ``handoffs``, keyed by name."""
    registry: Dict[str, Agent] = {}
    stack = [entry]
    while stack:
        agent = stack.pop()
        if agent.name in registry:
            continue
        registry[agent.name] = agent
        stack.extend(agent.handoffs)
    return registry

drive_agents(session, entry, *, max_steps=8, max_messages=40, token=None, timeout_s=5.0, send_events=True) async

Channel helper: reload history, then run :func:run_multi_agent per turn.

The active agent persists across turns within a session, and is restored on reconnect from persisted handoff frames (send_events=True); otherwise a resumed session restarts at entry. token forwards to tool calls under whichever agent is active. See :func:~istos.agent.loop.drive_channel for the send_events payload shapes.

Source code in src/istos/agent/multi.py
async def drive_agents(
    session: ChannelSession,
    entry: Agent,
    *,
    max_steps: int = 8,
    max_messages: Optional[int] = 40,
    token: Optional[Union[bytes, str]] = None,
    timeout_s: float = 5.0,
    send_events: bool = True,
) -> None:
    """Channel helper: reload history, then run :func:`run_multi_agent` per turn.

    The active agent persists across turns within a session, and is restored on
    reconnect from persisted ``handoff`` frames (``send_events=True``); otherwise
    a resumed session restarts at ``entry``. ``token`` forwards to tool calls
    under whichever agent is active. See :func:`~istos.agent.loop.drive_channel`
    for the ``send_events`` payload shapes.
    """
    registry = build_registry(entry)
    history = await session.history()
    messages = history_to_messages(history)
    active = _active_from_history(history, registry, entry)

    async for msg in session:
        messages.append({"role": "user", "content": user_text(msg)})
        async for event in run_multi_agent(
            active, messages,
            max_steps=max_steps, max_messages=max_messages,
            token=token, timeout_s=timeout_s,
        ):
            if event.kind == "handoff" and event.name is not None:
                target = registry.get(event.name)
                if target is not None:
                    active = target
            if event.kind == "done":
                continue
            if send_events:
                await session.send({
                    "kind": event.kind,
                    "content": event.content,
                    "name": event.name,
                    "arguments": event.arguments,
                    "tool_call_id": event.tool_call_id,
                    "error": event.error,
                })
            elif event.kind == "message" and event.content is not None:
                await session.send(event.content)

drive_channel(session, model, tools, *, system=None, max_steps=8, max_messages=40, token=None, timeout_s=5.0, send_events=True) async

Channel helper: reload history, then run :func:run_agent per inbound turn.

By default each :class:AgentEvent is sent as a dict {"kind", "content", …}. Set send_events=False to send only the final message content (plain string). max_messages bounds the reused log so a long-lived session does not grow unboundedly; pass None to disable.

Source code in src/istos/agent/loop.py
async def drive_channel(
    session: ChannelSession,
    model: Model,
    tools: Sequence[MeshTool],
    *,
    system: Optional[str] = None,
    max_steps: int = 8,
    max_messages: Optional[int] = 40,
    token: Optional[Union[bytes, str]] = None,
    timeout_s: float = 5.0,
    send_events: bool = True,
) -> None:
    """Channel helper: reload history, then run :func:`run_agent` per inbound turn.

    By default each :class:`AgentEvent` is sent as a dict
    ``{"kind", "content", …}``. Set ``send_events=False`` to send only the final
    ``message`` content (plain string). ``max_messages`` bounds the reused log so
    a long-lived session does not grow unboundedly; pass ``None`` to disable.
    """
    history = await session.history()
    messages = history_to_messages(history, system=system)
    if system and not any(m.get("role") == "system" for m in messages):
        messages.insert(0, {"role": "system", "content": system})

    async for msg in session:
        messages.append({"role": "user", "content": user_text(msg)})
        async for event in run_agent(
            model, tools, messages,
            max_steps=max_steps, max_messages=max_messages,
            token=token, timeout_s=timeout_s,
        ):
            if event.kind == "done":
                continue
            if send_events:
                await session.send({
                    "kind": event.kind,
                    "content": event.content,
                    "name": event.name,
                    "arguments": event.arguments,
                    "tool_call_id": event.tool_call_id,
                    "error": event.error,
                })
            elif event.kind == "message" and event.content is not None:
                await session.send(event.content)

history_to_messages(history, *, system=None, include_tools=True)

Map a durable channel log ([{dir, data, ts}, …]) into chat messages.

With include_tools (the default) the tool transcript is reconstructed: each persisted tool_call frame becomes an assistant tool_calls message followed by the tool message carrying its result, so a reconnecting agent sees the same context it had live. A tool_call with no matching tool_result in the log (a crash mid-tool) is dropped so the sequence stays valid. Set include_tools=False to rebuild plain text only.

Source code in src/istos/agent/loop.py
def history_to_messages(
    history: Sequence[dict],
    *,
    system: Optional[str] = None,
    include_tools: bool = True,
) -> List[dict]:
    """Map a durable channel log (``[{dir, data, ts}, …]``) into chat messages.

    With ``include_tools`` (the default) the tool transcript is reconstructed:
    each persisted ``tool_call`` frame becomes an assistant ``tool_calls`` message
    followed by the ``tool`` message carrying its result, so a reconnecting agent
    sees the same context it had live. A ``tool_call`` with no matching
    ``tool_result`` in the log (a crash mid-tool) is dropped so the sequence stays
    valid. Set ``include_tools=False`` to rebuild plain text only.
    """
    results_by_id: Dict[str, dict] = {}
    if include_tools:
        for turn in history:
            if turn.get("dir") != "out":
                continue
            data = turn.get("data")
            if isinstance(data, dict) and data.get("kind") == "tool_result":
                tid = data.get("tool_call_id")
                if tid is not None:
                    results_by_id[tid] = data

    messages: List[dict] = []
    if system:
        messages.append({"role": "system", "content": system})
    for turn in history:
        data = turn.get("data")
        direction = turn.get("dir")
        if direction == "in":
            messages.append({"role": "user", "content": user_text(data)})
        elif direction == "out":
            if not isinstance(data, dict):
                if user_text(data):
                    messages.append({"role": "assistant", "content": user_text(data)})
                continue
            kind = data.get("kind")
            if kind == "tool_call":
                if not include_tools:
                    continue
                tid = data.get("tool_call_id")
                result = results_by_id.get(tid) if isinstance(tid, str) else None
                if result is None:
                    continue
                messages.append(_tool_call_message(data))
                messages.append({
                    "role": "tool",
                    "tool_call_id": tid,
                    "content": str(result.get("content") or ""),
                })
                continue
            if kind in ("tool_result", "done", "handoff"):
                continue
            text = data.get("content") if kind == "message" else user_text(data)
            if text:
                messages.append({"role": "assistant", "content": str(text)})
    return messages

run_agent(model, tools, messages, *, max_steps=8, max_messages=None, token=None, timeout_s=5.0) async

Run plan → tool → observe until the model returns text or max_steps.

Mutates messages in place so the caller can keep a multi-turn conversation. Each mesh tool call forwards token on query_once. Pass max_messages to bound the log before each completion (system prompt kept).

Source code in src/istos/agent/loop.py
async def run_agent(
    model: Model,
    tools: Sequence[MeshTool],
    messages: List[dict],
    *,
    max_steps: int = 8,
    max_messages: Optional[int] = None,
    token: Optional[Union[bytes, str]] = None,
    timeout_s: float = 5.0,
) -> AsyncIterator[AgentEvent]:
    """Run plan → tool → observe until the model returns text or ``max_steps``.

    Mutates ``messages`` in place so the caller can keep a multi-turn
    conversation. Each mesh tool call forwards ``token`` on ``query_once``. Pass
    ``max_messages`` to bound the log before each completion (system prompt kept).
    """
    if max_steps < 1:
        raise ValueError("max_steps must be >= 1")

    catalog = _by_name(tools)
    schemas = [t.openai_schema() for t in tools] or None

    for step in range(max_steps):
        if max_messages is not None:
            _trim_messages(messages, max_messages)
        with span(
            "istos.agent.completion",
            {
                "gen_ai.operation.name": "chat",
                "istos.agent.step": step + 1,
                "istos.agent.messages": len(messages),
                "istos.agent.tools": len(tools),
            },
        ) as sp:
            reply = await model.complete(messages, tools=schemas)
            set_span_attributes(sp, _completion_attrs(reply))
        messages.append(_assistant_message(reply))

        if not reply.tool_calls:
            if reply.content:
                yield AgentEvent(kind="message", content=reply.content)
            else:
                # No text and no usable tool call — never end a turn silently.
                yield AgentEvent(
                    kind="message",
                    content="The model returned an empty response.",
                    error=True,
                )
            yield AgentEvent(kind="done")
            return

        for tc in reply.tool_calls:
            yield AgentEvent(
                kind="tool_call",
                name=tc.name,
                arguments=tc.arguments,
                tool_call_id=tc.id,
            )
            result_text, is_error = await _run_tool(
                catalog, tc, token=token, timeout_s=timeout_s,
            )
            messages.append({
                "role": "tool",
                "tool_call_id": tc.id,
                "content": result_text,
            })
            yield AgentEvent(
                kind="tool_result",
                name=tc.name,
                content=result_text,
                tool_call_id=tc.id,
                error=is_error,
            )

        _logger.debug(
            "Agent step %s finished %s tool call(s)",
            step + 1, len(reply.tool_calls),
            extra={"step": step + 1, "n_tools": len(reply.tool_calls)},
        )

    # Hit the ceiling while still tool-calling — surface what we have.
    yield AgentEvent(
        kind="message",
        content=f"Stopped after {max_steps} tool steps without a final answer.",
    )
    yield AgentEvent(kind="done")

run_multi_agent(active, messages, *, max_steps=8, max_messages=None, token=None, timeout_s=5.0) async

Run the loop starting at active, switching agents on handoff.

Mutates messages in place (shared history). Emits the same events as :func:~istos.agent.loop.run_agent plus handoff when the active agent changes; the last handoff event names the agent that should drive the next turn. token is forwarded to every tool call, across handoffs.

Source code in src/istos/agent/multi.py
async def run_multi_agent(
    active: Agent,
    messages: List[dict],
    *,
    max_steps: int = 8,
    max_messages: Optional[int] = None,
    token: Optional[Union[bytes, str]] = None,
    timeout_s: float = 5.0,
) -> AsyncIterator[AgentEvent]:
    """Run the loop starting at ``active``, switching agents on handoff.

    Mutates ``messages`` in place (shared history). Emits the same events as
    :func:`~istos.agent.loop.run_agent` plus ``handoff`` when the active agent
    changes; the last ``handoff`` event names the agent that should drive the
    next turn. ``token`` is forwarded to every tool call, across handoffs.
    """
    if max_steps < 1:
        raise ValueError("max_steps must be >= 1")

    for step in range(max_steps):
        if max_messages is not None:
            _trim_messages(messages, max_messages)

        handoff_by_tool = {_transfer_tool_name(t): t for t in active.handoffs}
        catalog = _by_name(active.tools)
        schemas = [t.openai_schema() for t in active.tools]
        schemas += [_handoff_schema(t) for t in active.handoffs]

        with span(
            "istos.agent.completion",
            {
                "gen_ai.operation.name": "chat",
                "istos.agent.name": active.name,
                "istos.agent.step": step + 1,
                "istos.agent.messages": len(messages),
                "istos.agent.tools": len(active.tools),
                "istos.agent.handoffs": len(active.handoffs),
            },
        ) as sp:
            reply = await active.model.complete(
                _with_system(messages, active.system), tools=schemas or None
            )
            set_span_attributes(sp, _completion_attrs(reply))
        messages.append(_assistant_message(reply))

        if not reply.tool_calls:
            if reply.content:
                yield AgentEvent(kind="message", content=reply.content)
            else:
                yield AgentEvent(
                    kind="message",
                    content="The model returned an empty response.",
                    error=True,
                )
            yield AgentEvent(kind="done")
            return

        # Resolve every tool call against the agent that produced them, so each
        # gets a tool response (the API requires one per call). A handoff wins at
        # the end of the batch; the last transfer sets the next active agent.
        next_active = active
        for tc in reply.tool_calls:
            target = handoff_by_tool.get(tc.name)
            if target is not None:
                messages.append({
                    "role": "tool",
                    "tool_call_id": tc.id,
                    "content": f"Transferred to {target.name}.",
                })
                yield AgentEvent(
                    kind="handoff",
                    name=target.name,
                    content=f"{active.name} -> {target.name}",
                    tool_call_id=tc.id,
                )
                next_active = target
                continue

            yield AgentEvent(
                kind="tool_call",
                name=tc.name,
                arguments=tc.arguments,
                tool_call_id=tc.id,
            )
            result_text, is_error = await _run_tool(
                catalog, tc, token=token, timeout_s=timeout_s,
            )
            messages.append({
                "role": "tool",
                "tool_call_id": tc.id,
                "content": result_text,
            })
            yield AgentEvent(
                kind="tool_result",
                name=tc.name,
                content=result_text,
                tool_call_id=tc.id,
                error=is_error,
            )

        if next_active is not active:
            _logger.debug(
                "Handoff %s -> %s", active.name, next_active.name,
                extra={"from": active.name, "to": next_active.name},
            )
            active = next_active

    yield AgentEvent(
        kind="message",
        content=f"Stopped after {max_steps} steps without a final answer.",
    )
    yield AgentEvent(kind="done")

tools_from_handlers(app, *, prefixes=None)

Build :class:MeshTool entries from the app's @handle registry.

Plumbing under .istos/ is skipped. Pass prefixes to whitelist (exact key expressions). Schemas come from the same path MCP uses.

Source code in src/istos/agent/tools.py
def tools_from_handlers(
    app: Any,
    *,
    prefixes: Optional[Sequence[str]] = None,
) -> List[MeshTool]:
    """Build :class:`MeshTool` entries from the app's ``@handle`` registry.

    Plumbing under ``.istos/`` is skipped. Pass ``prefixes`` to whitelist
    (exact key expressions). Schemas come from the same path MCP uses.
    """
    allow = set(prefixes) if prefixes is not None else None
    out: List[MeshTool] = []
    for h in app._handlers:
        if h.prefix.startswith(".istos/"):
            continue
        if allow is not None and h.prefix not in allow:
            continue
        try:
            schemas = get_function_schemas(h.func)
        except Exception:
            schemas = {}
        params = schemas.get("payload_schema") or {"type": "object", "properties": {}}
        out.append(
            MeshTool(
                h.prefix,
                app=app,
                description=(inspect.getdoc(h.func) or "").strip() or tool_name(h.prefix),
                parameters=params,
            )
        )
    return out

user_text(msg)

Pull a user string out of a channel message (str or common dict shapes).

Source code in src/istos/agent/loop.py
def user_text(msg: Any) -> str:
    """Pull a user string out of a channel message (str or common dict shapes)."""
    if isinstance(msg, str):
        return msg
    if isinstance(msg, dict):
        for key in ("text", "content", "message", "prompt"):
            val = msg.get(key)
            if isinstance(val, str) and val:
                return val
        return json.dumps(msg)
    return str(msg)