Skip to content

Callbacks as tools

A flow drives an agent by saying things to it. This is the other direction: a function the flow wrote, put in front of the agent as a tool. The agent reaches for it, your code runs — in the flow's process, with the flow's variables — and what it answers is what the agent reads back.

Reach for it when the agent needs something only the flow has: another agent, another flow, a queue, a database, a decision that is yours to make.

Try it

python
# .humanize/flows/delegating/__init__.py
"""Build here, and let the builder call the reviewer whenever it wants one."""

from typing import NamedTuple

from pydantic import BaseModel, Field

from hmz.flows import Agent, Tool, flow


class Reviewing(BaseModel):
    """What the builder calls the review tool with."""

    path: str = Field(description="the file to have read")


class Agents(NamedTuple):
    builder: Agent
    reviewer: Agent


@flow
def run(agents: Agents, task: str) -> None:
    working = agents.builder.new()
    working.offers(
        [
            Tool(
                name="review",
                about="have the reviewer read one file and say what is wrong with it",
                takes=Reviewing,
                call=lambda said: agents.reviewer(f"Review {said.path}. Be brief."),
            )
        ]
    )
    working(f"{task}\n\nUse the review tool before you say you are done.")
sh
hmz exec -f delegating -a claude/claude-opus-5:max -a codex/gpt-5.6-sol:high "write the parser"

The builder decides when it wants a review, and the reviewer's turn happens inside the builder's — which is a thing no prompt can arrange.

An agent that calls a flow

The callback is the flow's own code, so it may do whatever the flow may do, including start another flow and wait for it:

python
from hmz.flows import Tool, load


class Chasing(BaseModel):
    task: str = Field(description="what to have the loop do")


working.offers(
    [
        Tool(
            name="chase",
            about="run the flame-chase loop on one task and report what it came to",
            takes=Chasing,
            call=lambda said: load("official/flame_chase")(agents, said.task),
        )
    ]
)

That is an agent deciding, mid-turn, that a piece of work wants a loop of its own — and getting one. Nothing about it is written into any backend.

What a Tool is

namewhat the agent calls it. Name it for what it does; that is what a model reaches by
aboutwhat it is for, said to the model. The whole of what it knows about when to use it, so write a sentence
takesa pydantic model of the arguments, or None for a tool that takes nothing. The model is the whole of what the agent is told — fields, types, which are required, and each description
callwhat to run. Given the model (nothing where takes is None). What it answers goes back to the agent as text; None reads as done

Where it is said

On the conversation, because that is where a flow is when it has something to offer:

python
working.offers([...])      # from the next turn on
working.offers(None)       # and now it is offering none

What is actually in front of the model is the agent's list, because a CLI is told about its tools where it is started and some of these are started once per agent. Three things follow:

  • Two conversations of one agent offering a tool of one name are offering one tool.
  • Changing the list between two turns — offering one, taking one back, swapping one for another — restarts the Claude holding the conversation, and the Codex app server that agent holds, and resumes the same conversation. A process started without the tool has never heard of it; one started with a tool that is gone can still reach for it.
  • That restart is agent-wide: every live session of the agent starts a new process at its next turn. A conversation closing takes its own offer back, so opening and dropping conversations in a loop restarts a sibling once per drop.

Which backends take one

python
session.takes_tools      # True where the flow's callbacks can reach this backend
backendhow
claude--mcp-config on its own command line
codex-c mcp_servers.humanize… on the app server this agent holds
everything elseno way of being told — offers raises NotImplementedError

Refused rather than quietly never offered: a tool the model never sees is a flow that quietly does not do what it says.

Nothing of the person at this machine's configuration is written either way. Their own MCP servers stay exactly as they were, and this flow's tool goes away with this flow.

How it actually gets there

The road is the Model Context Protocol, that being the one way every one of these CLIs takes a tool it was not shipped with. What the backend is handed is a command to run:

hmz tools --at /tmp/humanize-tools-XXXX/tools.sock

which relays its pipe to a socket in the flow's process. So the function that runs is the one the flow wrote, on this interpreter, in this process — a tool server started as a program of its own would be a subprocess with none of the flow's variables in it.

Nothing is started until something is offered. An agent whose flow hands it no callbacks has no socket, no thread and no bridge, and its turns are the turns they always were.

When a callback goes wrong

A callback that raises is the tool failing, not the flow. The model is told what went wrong, in words it can act on, and is free to call it again correctly. A flow must not end because a model called one of its tools wrongly.

The callback runs on the thread serving the call, which is not the thread the flow is on. A callback that touches what the flow is touching answers for that itself — the usual lock.

See also

Released under the Apache-2.0 licence.