Flows
A flow is a directory: an __init__.py with a function marked @flow in it, taking the agents and the task, whatever that imports beside it, and a skills/ of the skills the flow works by. It is the loop: which agent is asked what, in what order, and when to stop.
my_loop/
├── __init__.py the flow
├── _prompts.py whatever it imports, which travels with it
└── skills/ what its agents are given, mounted onto every session they open
└── review-notes/
└── SKILL.mdEverything a flow needs lives in that directory, which is what makes a flow a thing you can copy, fork and edit whole — f on one in /flow writes a copy into .humanize/flows/.
A single .py file is a flow too. A flow is a module, and that is the other shape one has: .humanize/flows/twice.py is -f twice, exactly as a directory of that name would be. It brings no skills — what is beside it is the other flows, and none of it came with that one — so a flow that grows a skills/ is a flow that becomes a directory. Where both exist under one name, the directory wins.
It is ordinary Python. There is no DSL, no graph to declare, no state machine — a flow may branch, sleep, read files, shell out, and give up, because it is just a function.
The contract
Three rules, and that is the whole of it.
1. A function marked @flow, taking the agents and the task. What it is called is up to you — the mark is what makes it a flow, not the name.
from hmz.flows import flow
@flow
def run(agents: tuple[AgentBase], task: str) -> None:
...2. The annotation on agents says how many the flow drives. A fixed-length tuple, or a NamedTuple of them. tuple[AgentBase, ...] is any number, which is no answer to the question, and is refused.
3. That annotation has to be readable at runtime. Import AgentBase normally, not under if TYPE_CHECKING — a count nothing can read back is not one a command line can be held to.
"""Two passes over the same task."""
from hmz.agents import AgentBase
from hmz.flows import flow
@flow
def run(agents: tuple[AgentBase], task: str) -> None:
(agent,) = agents
session = agent.new()
session(task)
session("Now review what you just did, and fix anything that is wrong.")Anything else the file does as it is imported is the flow's own business and fails as it would anywhere — a flow that reads a prompt file beside it and does not find it is not reported as a command line to correct.
A flow may also be async def. Everything else on this page is the same either way.
One flow may hold several flows: @flow is the one it holds under its directory's own name, and @flow(name="…") is one of the rest, run as <flow>:<name>.
A flow may also name skills that live in somebody else's repository, and everything it brings is mounted onto every session its agents open.
And a flow may say it can be picked up where the last run of it left off: @flow(resumable=True) is handed a dict as its last argument, holding whatever it wrote there last time.
A flow that waits for more than one thing
A loop that has more than one turn going at a time has to be able to wait for several things at once, so a flow may be written as a coroutine:
import asyncio
from hmz.agents import AgentBase
from hmz.flows import flow
@flow
async def run(agents: tuple[AgentBase, AgentBase], task: str) -> None:
while True:
acted, reviewed = await asyncio.gather(
agents[0].aturn(task, suppress=True),
agents[1].aturn(f"Read the repository and say what is wrong: {task}", suppress=True),
)Nothing about starting it changes: hmz exec -f … and the interface run a coroutine flow the same way they run any other, on a loop of the flow's own, and the run is over when run returns. The count of its agents, the settings it declares, the cycle it is written down as and the way it is stopped are all exactly as they are for a flow that is a plain function.
Every call that runs a turn has an awaited twin — agent.aturn, session.aturn, agent.apursue — and agent.abatch runs a whole fan-out of them. See Agents › Awaiting a turn.
@flow
async def run(agents: tuple[AgentBase], task: str) -> None:
(agent,) = agents
# One session per shard, all of them at once, answers in the order they were asked for.
said = await agent.abatch([f"{task}\n\nShard {at} of 200." for at in range(200)])Two rules of thumb: turns of one session are still a sequence, whoever awaits them — a conversation is a conversation — and a flow that awaits nothing is a flow that runs one turn at a time, which is what most of them want.
Write the flow as a plain def run unless it has something to wait for. Both are flows; neither is the newer one.
How many agents, and what they are for
The count is checked before the first turn:
$ hmz exec -f official/rlar -a claude/claude-opus-4-8:high "fix the build"
hmz exec: error: official/rlar: the flow drives 2 agents, 1 givenwhich is what keeps a two-agent flow started with one from failing on an unpacking hours into a loop, with a turn's work already behind it.
A NamedTuple says what each agent is for as well as how many there are:
from typing import NamedTuple
from hmz.agents import AgentBase
class Agents(NamedTuple):
"""The two this drives: one that works in a session, and one that arrives fresh."""
actor: AgentBase
reviewer: AgentBase
@flow
def run(agents: Agents, task: str) -> None:
working = agents.actor.new()
...The names are not only for the flow's own readability. Everything that has to talk about an agent uses them:
- The agents page of
/flowasks what the reviewer runs, rather than what agent 2 of 2 runs. - The line above the prompt says
reviewer · claude/claude-opus-4-8:high. - A trace groups that agent's sessions under
reviewer. - What each agent was set to run is remembered per role, so a flow that grows an agent in the middle does not hand the reviewer's model to the builder.
An agent that was named where it was made keeps that name; one that was not takes the name the flow gives it, before anything is written down about the run.
Settings of the flow's own
A flow that has settings says so by taking a third argument, annotated with a pydantic model or None:
from typing import Literal
from pydantic import BaseModel, Field
from hmz.agents import AgentBase
class Config(BaseModel):
"""What this flow takes."""
rounds: int = Field(default=3, ge=1, le=9, description="how many times round")
mode: Literal["fast", "slow"] = Field(default="fast", description="which way")
@flow
def run(agents: tuple[AgentBase], task: str, config: Config | None = None) -> None:
setting = config or Config()
...That is the whole of it. The model is what asks: the fields are the questions, their types say how each one is answered, description is the line shown beside it, and whatever the model refuses is what the flow will not run.
- The sheet the interface puts up as a flow is chosen is that model with a cursor on it:
/flowasks it between choosing the flow and choosing its agents. See TUI › Setting a flow up. - What you set is remembered per flow, so a flow of twenty settings is not one to answer again every morning.
Nonemeans nobody set it up, and is what the flow gets fromhmz exec. Fall back to the model's own defaults, as above, and the flow runs the same either way.
A flow with many settings groups them, so the sheet has parts rather than one long list:
gen_idea: bool = Field(
default=True,
description="open the idea into a repo-grounded draft",
json_schema_extra={"section": "gen-idea · open the idea into a draft"},
)Combinations the flow cannot run belong in the model, not in run:
@model_validator(mode="after")
def _settles(self) -> "Config":
if self.fast and self.careful:
raise ValueError("fast and careful do not go together")
return selfwhich is refused where it was typed rather than an hour into the run.
Two rules, both for the same reason the agents annotation has them: the model has to be readable at runtime — import pydantic normally, not under if TYPE_CHECKING — and it is read by running the file, so the class the interface asked with is not the same object as the class the run is handed. What is carried across is the fields, which Runner reads back into the model the flow has just declared. A flow handed a config of another model is refused before its first turn, as a flow handed the wrong number of agents is.
A flow that can be picked up
A loop meant to run for a week is a loop that will be stopped and started: a machine goes down, somebody presses esc, a turn takes the process with it. So a flow may say it can be picked up where the last run of it left off, and one that does takes a dict as its last argument — after the config, for a flow that takes one — holding whatever it wrote there last time.
"""One pass per file, however often it is stopped."""
from pathlib import Path
from typing import Any
from hmz.agents import AgentBase
from hmz.flows import flow
@flow(resumable=True)
def run(agents: tuple[AgentBase], task: str, state: dict[str, Any]) -> None:
(agent,) = agents
left = state.get("left") or sorted(str(one) for one in Path("src").rglob("*.py"))
while left:
agent(f"{task}\n\nThis file: {left[0]}", suppress=True)
left = left[1:]
state["left"] = left # writing it into the state is what saves itIt is not a second copy of the transcript. The backends keep that, and the run's cycle already says which sessions it opened. What belongs here is the handful of things the loop itself is keeping track of — which round it is on, which files it has been through, what it has decided so far — which is the part of a run nothing else knows.
It lives in the run's own cycle, as state.json, under the name the flow was run as. A flow that called another is two flows and each keeps its own, side by side in that one file: neither writes the other's, and each is picked up as itself.
It is saved as the flow writes it. Setting a key, removing one, update, setdefault — each of them writes the file again, because a run worth picking up is one that was stopped or killed rather than one that ended tidily, and state written only at the end is state a stopped run has none of. Something written inside a value it holds — a list appended to, a dict of its own written into — is a change no mapping can see, and is saved when the run ends.
Keep to what JSON holds. Anything else is written as its str, so a Path put in comes back out a string; and a value that cannot be written at all leaves the last save standing rather than ending the run, since a loop that died because it could not write down where it had got to would be worse than one carrying on from a round ago.
Running the flow again is what picks it up. There is no flag for it: hmz exec -f weekly twice in one directory is one loop carried on, from the last run of it here that left anything — a run that wrote nothing is nothing to pick up, so what carries on is the run before it. In the interface, /cycles marks the runs whose flow said so, and enter on one offers carry on from here where the flow still says it, which runs that run's own flow on that run's own agents with what it was asked to do. From Python it is an argument:
Runner("weekly", agents, resume=cycle).run("go through the tests")A run that was picked up is a run of its own. A cycle is never reopened, so what carries on is written into a cycle of its own whose began line says which run it was picked_up from — and a week of stops and starts reads as the week it was rather than as one enormous run.
Whether a flow can be picked up at all is asked of the flow rather than of the run, since a flow may have been rewritten since it last ran:
from hmz.cycle import resumed, state
from hmz.runner import resumes
resumes("weekly") # what the flow says now, read by running it
at = resumed("weekly") # the run its next run would pick up, or None
if at is not None:
state(at, "weekly") # what that run left thereA flow that says nothing is run from the top every time, which is what every flow was before this, and a run pointed at a cycle to pick up ignores it, having nowhere to put what is there. One that says it can be picked up and takes no such argument is handed one it has no place for, and says so at the first call rather than starting over in silence.
Asking for an agent that can do something
Not every backend runs every moment. A flow that hangs a hook on one only some of them run says so where it declares the place, by writing the moment beside the type:
from typing import Annotated, NamedTuple
from hmz.agents import AgentBase, Moment
class Agents(NamedTuple):
"""The two this drives: one that is gated, and one that reads its work."""
builder: Annotated[AgentBase, Moment.PERMISSION_REQUEST]
reviewer: AgentBaseAnnotated is the whole of it: the type is still AgentBase, so the flow reads and type-checks exactly as it did, and what is written beside it is what the place asks of whoever fills it. Several moments are several arguments.
A goal is asked for the same way. agent.pursue(objective) is the backend's own goal feature — the agent decides for itself that the objective has been met, and until it does, a turn that would have ended starts another. Four backends have one (Claude Code, codex, dsh, Kimi), so a flow built on it says so:
from hmz.agents import AgentBase, Goal
class Agents(NamedTuple):
"""The one it drives, which has to have a goal of its own."""
worker: Annotated[AgentBase, Goal]Both are checked before the first turn, for the same reason the count is:
$ hmz exec -f gated -a kimi/kimi-code/k3:high -a kimi/kimi-code/k3:high "fix the build"
hmz exec: error: gated: builder has to run PermissionRequest, which kimi does not
$ hmz exec -f pursuing -a pi/openai-codex/gpt-5.5:high "fix the build"
hmz exec: error: pursuing: worker is run under a goal, which pi has no feature forand the agents page of /flow offers only the CLIs that would work for that place, so it cannot be chosen wrong there at all.
Where each agent works
Where an agent's turns land is declared the same way, and by the same file: the flow writes it beside the type.
from typing import Annotated, NamedTuple
from hmz.agents import AgentBase, Isolated, Remote
class Agents(NamedTuple):
"""The three this drives, and the three places they work."""
builder: Annotated[AgentBase, Remote] # may be pointed at a machine
tester: Annotated[AgentBase, Isolated("python:3.12")] # a container of the flow's own
reviewer: AgentBase # here, and nowhere else| Beside the type | Where that agent works |
|---|---|
| (nothing) | this machine, and it cannot be pointed anywhere else |
Remote | wherever whoever chose the agent pointed it — the only kind of place that may be pointed at all — and here where nobody did |
Isolated("<image>") | a container of that image, which nobody configures and nobody is asked about |
This is a change. A machine used to be a setting of the agent that anything could reach, so any agent of any flow could be pointed anywhere. It is still a setting of the agent — that is how a Remote place is filled — but a flow is written for one shape of work, and one whose agents read this project cannot have one of them reading somebody else's. So the flow says which of them may be sent elsewhere, and nothing above it can say otherwise.
Both refusals land before the first turn, for the reason the count does:
onbox: reviewer runs on this machine -- this flow does not say it works anywhere else, so it cannot be pointed at one
onbox: tester works in a container of this flow's own, so there is nothing to point it athmz exec prints either as hmz exec: error: … and runs nothing; the interface shows it as a red line and starts nothing. No -a spells a machine, so what runs into these is an agent built in Python or one moved on the interface's where row.
A place may say more than one thing — Annotated[AgentBase, Moment.STOP, Remote] is a place that must run that moment and may be moved. Several arguments, read one by one, in any order.
What the flow declared is readable without driving it:
from hmz.runner import wanted
wanted("official/rlar") # one Place per agent somebody has to choose:
# .name, .moments, .goal, .wherewhere is None, the Remote class itself, or the Isolated the flow wrote — which is how whatever chooses the agents knows which of them it may offer a machine for. What each answer comes to, and what a container of the flow's own actually is, is in Machines.
Hooks in a flow
A flow holds the agents, so it can hang a hook on one and take it down again as it goes. This is a Ralph loop that will not let a turn stop while the task file still says there is work:
from pathlib import Path
from hmz.agents import AgentBase, Moment, Occasion, Verdict
def run(agents: tuple[AgentBase], task: str) -> None:
(agent,) = agents
def unfinished(occasion: Occasion) -> Verdict | None:
if occasion.again < 5 and "- [ ]" in Path("TASK.md").read_text():
return Verdict(refused=True, because="TASK.md still has unticked boxes.")
return None
with agent.hooks.on(Moment.STOP, unfinished):
while "- [ ]" in Path("TASK.md").read_text():
agent(task, suppress=True)Everything a hook can do is in Agents › Hooks. Two things worth saying here:
- Hooks are on the agent, not the session, so one covers every session that agent opens — including the fresh one a Ralph loop makes each turn.
- A hook runs on the turn's own thread. One that takes a while is a turn that takes a while.
The person at the prompt
A place annotated HumanAgent is you, driven as an agent — which is what you are to a flow.
from typing import NamedTuple
from hmz.agents import AgentBase, HumanAgent
class Chat(NamedTuple):
assistant: AgentBase
human: HumanAgent
def run(agents: Chat, task: str) -> None:
conversation = agents.assistant.new()
said = task
while said:
answered = conversation(said, suppress=True)
said = agents.human(answered)Saying something to it is asking what to say next; what it answers with is what was typed.
Nobody is asked what the person runs, so a HumanAgent is not one of the agents -a names — the flow above is started with one -a and drives two. Run from a command line, where nobody is at a prompt, it answers with nothing, so the loop ends and the flow does the one thing it was given.
Running one
hmz exec -f <flow> -a <cli>/<model>:<effort> [-a ...] <task>One -a for each agent the flow drives, in the order it takes them. Full syntax in the CLI reference.
In the interface, /flow picks one by name — tab and shift+tab are for stepping between the agents of the flow that is running. Picking one while a flow runs is refused: esc stops it first, since a flow drives the agents it was handed and must not have them swapped underneath it.
Several flows in one file
Three phases of one thing are one thing to write and three to run. Give each mark a name, and each is a flow of its own, called <flow>:<name>:
"""Three phases of one thing."""
from hmz.flows import flow
@flow(name="gen-idea")
def first_pass(agents: Drafting, task: str, config: Idea | None = None) -> None:
"""Opens a loose idea into a repo-grounded draft."""
@flow(name="gen-plan")
def then_plan(agents: Planning, task: str, config: Plan | None = None) -> None:
"""Turns that draft into a plan both sides have converged on."""hmz exec -f official/humanize1:gen-idea -a claude/claude-opus-5:max "add undo to the editor"
hmz exec -f official/humanize1:gen-plan -a claude/claude-opus-5:max -a codex/gpt-5.6-sol:max ""The name is what you write in the mark and nothing else — a name written down where a flow is run should not change under whoever renames the function. @flow(about="…") says what it does where flows are listed, which is otherwise the first line of its docstring.
An implementation flow used only through calls() can stay out of those lists and the /flow picker without losing its name:
@flow(name="engine", selectable=False)
def engine(agents: Agents, task: str) -> None:
...It remains directly callable by <flow>:engine; selectable=False changes discovery only.
Each of them declares its own agents and its own settings, so the agents page asks two questions rather than five and setting one up shows one phase's flags rather than three phases' at once. What passes between them is whatever they write — a file, usually.
@flow marks; it does not wrap. The function is called exactly as it was. A file that marks one function with a bare @flow is one flow under the file's own name, which is most of them.
A flow that calls another flow
A flow is a loop over agents, and a loop worth having is one another loop can reach for. Ask for it by the same name -f takes, and you are handed the flow itself to run with the agents you already have:
from hmz.agents import AgentBase
from hmz.flows import flow
from hmz.runner import calls
@flow
def run(agents: tuple[AgentBase, AgentBase], task: str) -> None:
plan = calls("official/humanize1:gen-plan")
plan(agents, f"plan this first: {task}")
for _ in range(3):
agents[0].new()(task)calls takes what -f takes — ralph_loop, official/rlar, humanize1:gen-plan, a path of your own — so a flowverse is a library as well as a menu. A name nothing answers to is refused where you ask for it rather than an hour into your loop.
Hand it the agents it declares. A flow that drives one is called with one, in the tuple it declared them as — pass a list or a tuple and it arrives as that flow's own NamedTuple, named the way that flow names them. A flow that talks to the person may be handed one fewer, since nobody chooses the person; hand over your own if you have one, so that what it asks reaches whoever is at the prompt.
Nothing is renamed. The agents belong to the run that was started, and what has already been written down about them stays true.
It is read again at every call. calls holds the name rather than the function it found: each call runs the flow's entry point afresh, so a flow rewritten between two calls of it — by hand, or by an agent this very flow is driving — is the one that runs next. That is what makes a loop that improves its own flow a loop that then runs the improved one. A flow that was rewritten into something that is no longer a flow is refused at the call, the way a name that was wrong is refused at calls.
It brings its own skills. The called flow's skills/, and the repositories it declared, are mounted onto the sessions its agents open while it runs — and the agents are handed back carrying the calling flow's own when it returns, however it returns. A call refused — for settings the flow does not take, for an agent that cannot run a moment it declares, for a place run under a goal filled by an agent that has none — is a call that never happened, and leaves the agents exactly as it found them.
A wrapper flow may deliberately keep its own skills available inside the called flow:
calls("official/rlar", inherit_skills=True)(agents, task)The called flow's skills come first and win any same-name collision. Parent-only skills are then appended, and the agents are restored to exactly what the wrapper carried when the call returns or raises. Without the flag, calls remain isolated; a reviewer or other child flow is not implicitly given its caller's capabilities.
A flow that takes settings of its own takes them here too, as a third argument — an instance of that flow's model, or the fields to build one from:
calls("official/rlar")(agents, task, {"rounds": 9})They are read back through the flow's own model at the moment it is called, so a flow that takes no settings, or takes different ones, says so rather than quietly ignoring them.
A called flow answers with whatever it answers with, so one written as a coroutine is awaited by whoever called it:
@flow
async def run(agents: tuple[AgentBase], task: str) -> None:
await calls("official/rlar")(agents, task)What is running is both of them. hmz.runner.running() reports the flow that was started and whatever it called, innermost last; the interface names them on its status line and on /status, and the cycle records each call and each return. A flow that called another does not read as the flow somebody chose.
Where flows live
-f takes a name or a path. A name is looked for nearest first:
.humanize/flows/*/ | this project's own |
~/.humanize/flows/*/ | yours, in every project |
| — | the ones humanize ships, and every flowverse there is |
Nearest wins, so a flow of your own may stand in for one of humanize's by taking its name — a .humanize/flows/chat/ is what -f chat runs in that project. Which is what f in the flow menu is for: it copies the flow under the cursor into .humanize/flows/, whole, and from then on that name means your copy. In Python that is hmz.flows.fork(name, into=None), which copies a directory flow with its skills/ and a single-file flow as a file, and refuses a name you already have a copy of — in either shape, since a directory would otherwise take a single-file flow's name without touching the file it is in — rather than writing over it. A copy that fails partway leaves nothing behind, so the name is free to try again.
What a flow is called is another question. The ones humanize ships are called by a bare name; a flowverse's are called <flowverse>/<flow>, which is the one spelling nothing can stand in for; a flow of yours is called by its path, short enough to read:
chat | one humanize ships |
official/rlar | one the official flowverse holds |
.humanize/flows/chat | this project's own |
~/.humanize/flows/chat | yours, in every project |
So yours is listed beside humanize's rather than instead of it, -f takes either, and what each was set up to run is remembered apart — a flow of yours cannot quietly inherit the agents or the settings of the one it shares a name with.
Anything with a slash in it is a path, taken as given: a flow's directory, or a .py file to run as one — -f ./flows/mine and -f ./flows/mine.py both work, the directory being tried first. A directory whose name starts with _ is not a flow.
A flow imports what travels with it. While one is read, its own directory and the directory the flows are in are both on sys.path, and only while: import _prompts reaches the module beside the flow, and import _shared reaches what a flowverse keeps beside all of them. What a flow imports is not something the rest of the process can — and is forgotten as the flow is done with, so two flows that each keep a _prompts beside them each read their own, and one rewritten between two runs is read again rather than remembered.
mkdir -p .humanize/flows && cp -r my_loop .humanize/flows/
hmz exec -f my_loop -a claude/claude-opus-4-8:high "fix the build"
hmz exec -f ./somewhere/else -a claude/claude-opus-4-8:high "fix the build"The skills a flow brings
The skills/ inside a flow is what that flow works by, laid out the way every one of these CLIs lays a skill out — a directory apiece, each holding a SKILL.md. They are mounted onto every session the flow's agents open: copied where that backend reads a project's own skills for as long as the session lives, and taken away again after. Nothing is installed, and nothing the person at this machine installed is touched.
A flow may also name skills that live in somebody else's repository, where it is declared:
@flow(skills=("https://github.com/humanfia/flowverse#review-notes",))
def run(agents: Agents, task: str) -> None:
...which is a git URL anything can clone and, after the #, which of that repository's skills/* is wanted. Without one, every skill it holds is brought. Such a repository is cloned under ~/.humanize/skills/ and fetched again the next time a run asks for it.
The flow's own wins a name a repository also uses: a fork that edited a skill meant the edited one. A backend that reads no project skills of its own carries none of this — its skills are the ones its CLI installs, and humanize does not switch those on or off.
In a directory that holds several flows, the skills/ is all of theirs: it belongs to the directory, not to the entry point. What @flow(skills=…) names is read off the one that was asked for.
A repository that cannot be fetched stops the run before its first turn. hmz exec exits 2 with what git said, and the interface says it where the flow was started — a flow that works by a skill it has not got is not a flow to start and find out about an hour in. One that was fetched before and cannot be reached now runs on the copy already here. A #name the repository does not hold stops it the same way, and says what the repository does hold.
A flow that is one file brings no skills/ of its own, and may still name a repository. What is beside such a flow is the other flows; what it declares is fetched and mounted as any other flow's is.
A name is one skill. Where something of that name is already where the mount goes — the project's own, or another flow's mounted by a session that is still running — the flow's is left where it is and the session reads what is there. A flow called by another flow does not change what the flow that called it is working by.
Flowverses
A flowverse is a git repository with a flows/ directory in it: one directory per flow, each holding the __init__.py that is the flow, whatever it imports beside it, and the skills/ it brings. It is cloned into ~/.humanize/flowverses/<name>/, and every flow in its flows/ is then offered under that name. Nothing outside that directory is read, so the repository is free to have a README, a pyproject and a test suite of its own without any of it being taken for a flow.
Two are always there:
builtin | the flows in the package, which are the three below |
official | humanfia/flowverse, which is everything else humanize offers |
official is listed before it has been fetched — what there is to run is not the same question as what has been downloaded — and neither of the two can be taken away.
In the interface, /flowverses is where they live: a adds one, r fetches the one under the cursor again, d twice takes an added one away, and enter says what one holds. Adding one takes a URL or an owner/repo, and a name to keep it under if the repository's own name is not the one you want. /flow keeps the two keys that are about flows rather than about places: left and right, which walk these same places because that is which list of flows is being read, and f, which copies the flow under the cursor into this project.
A flow is Python, and reading one means running it — so listing what a flowverse holds runs the entry point of every flow in its flows/. Adding one is trusting that repository with this machine, exactly as installing a package is.
hmz flowverses is the same, said as arguments, for a machine being set up or a script: list, show, add, fetch, remove.
hmz exec -f official/rlar -a claude/claude-opus-5:max -a codex/gpt-5.6-sol:max "$(cat TASK.md)"A flow from a flowverse that has not been fetched says so rather than saying there is no such file: the name is right, the download has not happened.
Editing a flowverse's own copy does not keep: it is somebody else's repository, and fetching it again takes what that repository says now. f on a flow copies it into .humanize/flows/, where it is yours — and where the name then means your copy.
The flows humanize ships
Three, which are the shapes a flow takes. Each names the hmz exec line that starts it in its own docstring.
| Flow | Agents | What it does |
|---|---|---|
chat | 1 + you | One agent, one session, and every line typed between turns is a turn of it. Talking to a coding agent with no loop around it. This is what the interface opens on. |
ralph_loop | 1 | A fresh session every turn, so nothing carries over: the agent starts from the task and the repository each time. |
stateful_ralph | 1 | One session, held for the whole run, re-sent the task every turn. |
Both loops can be picked up, and what they keep is rounds: one left going for days is one that will be stopped, so running it again goes on from the round it reached rather than back at one. Nothing else carries — a session is opened rather than reopened, so stateful_ralph started again is a conversation of its own. chat keeps nothing: what was said is the conversation, and the backend logged it.
Their source is the best documentation of this API there is — src/hmz/flows/builtin/ in a checkout, or wherever pip put it.
The official flowverse
Everything else humanize offers is in humanfia/flowverse, which is fetched the first time somebody wants what is in it. Five of these are flowbench's loops, written against this API.
| Flow | Agents | What it does |
|---|---|---|
official/fixed_juice_ralph | 1 | Ralph with a governor on it: it moves the effort a rung a round to hold the agent to juice output tokens per turn of the model. |
official/continue_loop | 1 | Sends the task once, then keeps nudging continue. Until a turn lands the task is sent again — continue on its own would open a session that never saw it. |
official/goal | 1 | Ralph, with the task set as the agent's own goal. The loop only starts it over when it stopped without having met it. |
official/flame_chase | 2 | Two agents take turns on the same task. Each reads the repository, not a history. |
official/rlar | actor, reviewer | The actor works in one session and must remember; a fresh reviewer reads its work and must not. The review is the actor's next prompt, word for word, and the reviewer is also the one that says the task is finished — which is what ends the run. |
official/humanize1:gen-idea | drafter | Opens a loose idea into a repo-grounded draft. |
official/humanize1:gen-plan | planner, analyst | Turns that draft into a plan both sides have converged on. |
official/humanize1:rlcr | builder, reviewer | Builds the plan under review until nothing is left to say. Run it in a git repository. |
Every one of them but the two drafting phases can be picked up, each keeping the little it honestly can. The three Ralphs keep the round they reached, as rounds; fixed_juice_ralph keeps the rung its governor settled at as well, since a loop started again at the top of the ladder walks back down to it a paid turn at a time. flame_chase keeps whose turn is next, two turns in a row being the one thing a pair taking turns must not do. rlar keeps the review the actor is owed, word for word, which is the one thing a restart would otherwise throw away — and keeps nothing at all where the reviewer agreed, a run that is over being nothing to carry on. humanize1:rlcr keeps which .humanize/rlcr/ directory the loop is in and reads state.md back as it stands, rather than stamping a new directory beside a week of rounds. gen-idea and gen-plan keep nothing: each writes one file, and running one again is meant to write another.
humanize1 is PolyArch/humanize, and its three commands are three flows in one file — set up on their own agents, run one at a time, and handing to each other through the file each writes: the draft, then the plan. Every flag the plugin takes is a field on that phase's own settings, under the plugin's own name for it — --max, --full-review-round, --skip-impl, --agent-teams, --yolo, and the rest.
The loop is a hook. The plugin blocks Claude's exit and puts the round to Codex in a Stop hook; so does this, with a Moment.STOP hook on the builder. A round is the builder believing the whole plan is done and trying to stop, and what the reviewer says is what it hears instead. Its tool validators are hooks too, on Moment.PERMISSION_REQUEST, which is why the builder has to be a backend that runs it.
It writes what the plugin writes, where the plugin writes it: .humanize/rlcr/<timestamp>/ with state.md, goal-tracker.md, and a prompt, summary, contract and review per round.
Read Security before starting any of them.
Patterns
Ralph: forget every turn
while True:
agent(task, suppress=True)
time.sleep(5)agent(...) opens a session of its own and drops it. That is the whole of a Ralph loop.
Stateful: remember everything
session = agent.new()
while True:
session(task, suppress=True)Same agent, opposite behaviour. The flow decides, not the agent.
Fanning out: one agent, many turns at once
answers = agent.batch([f"Fix the tests in {path}" for path in paths], at_once=8)A session apiece, all of them going, answers in the order they were asked for. at_once is how many run at a time — leave it out and they all do. In a coroutine flow it is await agent.abatch(...), which is the same fan-out with the loop left free.
Actor and reviewer
The reviewer must arrive fresh, so it gets a new session each round while the actor keeps one:
def run(agents: Agents, task: str) -> None:
working = agents.actor.new()
said = working(task, suppress=True)
while True:
review = agents.reviewer(REVIEW_PROMPT, suppress=True)
said = working(review, suppress=True)Give the two the same model and effort and they are still two agents — which is the point: a trace reads the actor's session and the reviewer's rounds as two.
Asking a question rather than setting an agent to work
A loop that has to decide something — is this finished, does this plan belong to this repository — asks for the shape of the answer and reads a field, rather than looking for a word at the end of a paragraph:
class Review(BaseModel):
"""What one round's review comes to."""
model_config = {"extra": "forbid"}
done: bool = Field(description="True only if there is nothing left to do or to fix.")
notes: str = Field(description="What to say to the agent, passed on word for word.")
review = agents.reviewer(REVIEW_PROMPT + task, suppress=True, schema=Review)
if review is not None and review.done:
returnsuppress=True covers a review that never arrived and one that came back as something other than a Review: both are None, and both are a round to take again. This is what rlar ends on, and what humanize1 asks its analyst and its reviewer before it starts anything.
The same call to the person is a questionnaire: they are asked a question per field rather than shown a schema, and the model is built out of what they typed. So a flow settles what only a person can settle in the model it is going to run on — agents.human(asked, schema=Settled, suppress=True). See Agents.
Catching turns without wrapping every line
A flow is a loop, and a loop that catches its own turns is a try around every line of it. So || true is a word on the call rather than a block around it:
agent(task, suppress=True) # a turn that failed answers with nothing; the loop goes roundIt catches a turn that failed and nothing else — not an agent that was stopped, and not a backend that has no goal feature, which is a flow to correct.
Reading the repository between turns
There is nothing special to do. It is Python:
import subprocess
def head() -> str:
return subprocess.run(
["git", "rev-parse", "HEAD"], capture_output=True, text=True, check=False
).stdout
before = head()
agent(task, suppress=True)
if head() == before:
... # the turn changed nothingBuilding the agents yourself
-a reaches four of an agent's settings: the CLI, the model, the effort, and — after an @ — the provider whose account it runs as. A name, where the work lands and what it may do are settings of the agent that no -a spells, so a flow that needs one is handed agents built in Python — and a machine only where the flow's own place for that agent said Remote:
from hmz.agents import ClaudeCodeAgent, ClaudeCodeAgentConfig
from hmz.runner import Runner
config = ClaudeCodeAgentConfig(model="claude-opus-4-8", effort="high")
agents = [
ClaudeCodeAgent(config, name="actor"),
ClaudeCodeAgent(config, name="reviewer"),
]
Runner("official/rlar", agents).run("fix the build")Runner takes the same flow names and paths -f does, checks the count the same way, and writes the same cycle. See Agents for what those objects can do.
Stopping
A flow ends when run returns — most of the built-in ones never do, and are ended from outside:
- esc in the interface. (ctrl+c there ends one turn rather than the flow: the conversation being read is closed under its turn, which the flow reads as a turn that failed.)
- ctrl+c on a
hmz execcommand line. agent.stop()from anywhere.
Every agent is told to take no further turn. The turn under way is closed out, and the next call into that agent raises Stopped — which suppress=True deliberately does not catch, because a loop that carried on past it would never end. Let it propagate; the cycle then records the run as stopped by hand rather than as one that finished.
What the turn was doing is left where it got to. A stop that waited for a turn would not read as a stop — a model can think for minutes.
Testing a flow
A flow is a function, so drive it with something that is not a coding agent:
from collections.abc import Iterator
from hmz.agents import AgentBase, AgentConfig, Event, SessionBase
class FakeSession(SessionBase):
def _stream(
self, prompt: str, *, schema: type[BaseModel] | None = None
) -> Iterator[Event]:
yield Event(kind="result", text=f"answered: {prompt}")
class FakeAgent(AgentBase):
def new(self) -> FakeSession:
return FakeSession(self)
run((FakeAgent(AgentConfig(model="m", effort="high")),), "the task")humanize's own suite does this — tests/stubs.py has a shell-backed agent that runs the prompt as a shell script, so a test spells out exactly what the agent it stands in for would do.
To check only that a flow loads and declares what it should:
from hmz.runner import drives
assert drives("my_loop") == ("actor", "reviewer")