Skip to content

Your first flow ​

In this guide you write twice: a flow that has one coding agent do a task, then read its own work back and fix what is wrong. You run it on a real agent, then test it without spending a token. It takes about five minutes.

Write a flow when you would otherwise type the same instructions to an agent again and again: the flow types them for you, in the order you chose, and decides when to stop.

Before you start

  • Humanize installed and one coding agent CLI signed in. See Installation.

  • A flow run behind you. The User Guide starts there.

  • A git repository you don't mind an agent editing. To follow along exactly, make one:

    sh
    mkdir calc && cd calc && git init
    printf 'def add(a, b):\n    return a + b\n' > calc.py
    git add calc.py && git commit -m "add calc.py"

What a flow is ​

A flow is an async Python function with @flow on it. The decorator says three things the function needs, and the runtime hands it exactly those when it runs:

It declaresAsFilled by
the agents it drives, one per rolean AgentCollection subclasswhoever runs it, with -a ROLE=CLI/MODEL:EFFORT
the environments they work in: a directory on a machinean EnvCollection subclassthe runtime, for a LocalEnv; otherwise -e
its params: settings of its owna FlowParams subclass-p KEY=VALUE, or a form at the prompt

The function never names a CLI or a model. It names a role, builder, and asks it for work in two steps:

  • spawn opens a session: one conversation of one agent.
  • run takes a turn in that session, in an environment: sends one prompt and waits until the agent is done with it. It returns what the agent said.

A second run in the same session carries on the same conversation. That is all twice needs.

Write the flow ​

A flow is a directory under .hmz/flows/ in your project, named after the flow, with the code in its __init__.py:

sh
mkdir -p .hmz/flows/twice
python
# .hmz/flows/twice/__init__.py
from hmz.flows import (
    Agent,
    AgentCollection,
    EnvCollection,
    FlowContext,
    FlowParams,
    LocalEnv,
    flow,
)


class Agents(AgentCollection):  # ①
    builder: Agent


class Envs(EnvCollection):  # ②
    workspace: LocalEnv


@flow(agents=Agents, envs=Envs, params=FlowParams)  # ③
async def twice(  # ④
    task: str, *, agents: Agents, envs: Envs, params: FlowParams, ctx: FlowContext
) -> None:
    """Do the work, then read it back and fix what is wrong."""  # ⑤
    builder, workspace = agents["builder"], envs["workspace"]  # ⑥
    session = await builder.spawn()  # ⑦
    await builder.run(task, session=session, env=workspace)  # ⑧
    await builder.run(  # ⑨
        "Now review what you just did, and fix anything that is wrong.",
        session=session,
        env=workspace,
    )

What each part does ​

  1. class Agents(AgentCollection) declares the roles. builder: Agent says the flow drives one agent, called builder, and asks nothing of it beyond taking turns. The role's type is where you would ask for more, such as a hook or a goal: the flow is then refused on a CLI that cannot do it.
  2. class Envs(EnvCollection) declares where the agents work. workspace: LocalEnv is the directory the run was started in. The runtime fills a LocalEnv itself, so nobody names it on the command line.
  3. @flow(agents=…, envs=…, params=…) makes the function a flow and says what it takes. All three keywords are required. params=FlowParams means no params of its own. The flow's name is the function's name.
  4. The signature is fixed. A flow is async, takes the task as its one positional argument, and takes agents, envs, params and ctx as keyword-only arguments. ctx is the run's context: its budget, what it has spent, and state that survives a stop. twice does not use it, but still has to take it.
  5. The docstring's first line is what /flow shows beside the flow's name.
  6. agents["builder"] is the agent that fills the role, whichever CLI and model that is, and envs["workspace"] is the directory.
  7. spawn() opens a session: a conversation with nothing in it yet. Nothing is sent to the agent, and no CLI is started.
  8. run(task, session=…, env=…) sends the task as the first prompt, in the workspace, and waits for the turn to end. It returns the agent's answer as a string, which twice ignores. env=None, or no env at all, is the run's own workspace too: the directory the run was started in, which no role has to name.
  9. The second run is in the same session, so the agent remembers the first turn and can review its own change. A new spawn here would start a stranger with no memory of it.

The flow ends when the function returns. Every session it opened is closed then.

Run it ​

text
$@local/twice add a subtract function to calc.py
sh
hmz exec -f twice -a builder=claude/claude-sonnet-5-5:high -p budget.cost=1 \
    "add a subtract function to calc.py"
sh
hmz exec -f twice -a builder=codex/gpt-5.6-sol:high -p budget.cost=1 \
    "add a subtract function to calc.py"
  • At the prompt, your project's flows are offered as @local/<name>. The first time, the flow's menu asks what builder runs and what the run may spend. After that, the line alone starts it.
  • On the command line, -a builder=… fills the role by its name, as CLI/MODEL:EFFORT. Use a model your account can name: /flow lists them.
  • -p budget.cost=1 is the run's budget, in US dollars. hmz exec refuses to start a flow without one.

This is a real run on Claude Code:

text
● builder is working
● Read(/home/you/calc/calc.py)
● Edit(/home/you/calc/calc.py)
● I added `subtract(a, b)` to `calc.py`. It returns `a - b`. I haven't run it or added tests.
✻ input 6 · output 233 · cache_read 44.3k · cache_write 5.5k · $0.02 · claude-sonnet-5-5 · builder
I added `subtract(a, b)` to `calc.py`. It returns `a - b`. I haven't run it or added tests.
✻ Worked for 5s · builder
● builder is working
● Bash(cd /home/you/calc && cat calc.py && python3 -c "import calc; print(calc.subtract(5, 3), calc.subtract(1, 2.5))" && git s)
● Bash(cd /home/you/calc && rm -rf __pycache__ && git status --short)
● I found nothing wrong with the change.
  …
✻ input 6 · output 496 · cache_read 50.8k · cache_write 499 · $0.02 · claude-sonnet-5-5 · builder
I found nothing wrong with the change.
…
✻ Worked for 5s · builder

Read it turn by turn:

  • ● builder is working opens a turn, labelled with the role that is taking it.
  • ● Read(…), ● Edit(…), ● Bash(…) are the tools the agent reached for.
  • ✻ input … · $0.02 · … is what the turn spent, which counts against the budget. The dollar figure appears where the model has a published price.
  • The line after it is the turn's answer: the string run returned.
  • ✻ Worked for 5s closes the turn. The second turn follows in the same conversation, and the run ends when twice returns.

Check it worked ​

The agent's change is in your working tree, for you to read and commit:

sh
git diff
diff
diff --git a/calc.py b/calc.py
index 4693ad3..3b474e9 100644
--- a/calc.py
+++ b/calc.py
@@ -1,2 +1,6 @@
 def add(a, b):
     return a + b
+
+
+def subtract(a, b):
+    return a - b

The flow's own logic is worth a test too, and a test need not spend anything. The fake kit runs twice exactly as hmz exec does, on an agent that answers from a script:

python
# tests/test_twice.py
from hmz.sdk import fakes


async def test_twice_reads_its_own_work_back() -> None:
    builder = fakes.FakeAgentDriver()  # ①

    await fakes.run_fake(  # ②
        "twice", "add a --dry-run flag", agents={"builder": builder}
    )

    assert builder.prompts == [  # ③
        "add a --dry-run flag",
        "Now review what you just did, and fix anything that is wrong.",
    ]
sh
uvx --with hmz \
    --with pytest-asyncio pytest -q -o asyncio_mode=auto
text
.                                                                        [100%]
1 passed in 0.09s
  1. FakeAgentDriver() is a stand-in agent. With no script it answers "ok" to every turn, and it keeps every prompt it was sent.
  2. run_fake("twice", …) finds the flow by the same name -f takes, from the directory pytest runs in, fills builder with the fake, and runs the flow to the end.
  3. builder.prompts is what the flow said, in order. The assertion pins down that the task goes first and the review second, in the same session.

Testing a flow scripts answers, workspaces, budgets and resuming.

If it is refused ​

hmz exec checks the line and the flow before any agent starts, so a refusal costs nothing:

hmz exec: error: …Why, and the fix
twice needs an agent for 'builder'; specify each with -a ROLE=CLI/MODEL:EFFORTNo -a builder=… on the line. Add it.
twice requires a budget: specify with -p budget.cost=...,budget.duration=...,budget.output_tokens=...No budget. Add one, such as -p budget.cost=1.
twice: no flow is called 'twice'You are not in the project that holds .hmz/flows/twice/. cd into it, or pass -f ./path/to/twice.
twice: a flow takes `ctx` as a keyword argumentThe signature is missing one of its keyword arguments. Take all four, even the ones you do not use.
importing the flow at … failed: SyntaxError("'await' outside async function", …)async is missing from def. Write async def.

Where flows are found ​

A name is looked up nearest first, so a flow of yours can stand in for one of Humanize's by taking its name:

Put it inRun it withAt the prompt
.hmz/flows/twice/ in the project-f twice$@local/twice
~/.hmz/flows/twice/-f twice, when the project has none of that name$@user/twice
the flows Humanize ships, and those you installed from a flowverse-f ralph_loop, -f @theirs/review$ralph_loop, $@theirs/review
anywhere else-f ./path/to/twice

-f @local/twice and -f @user/twice say which one outright.

Variations ​

One file instead of a directory. .hmz/flows/twice.py is a flow too. Use a directory once the flow has files beside it, such as skills or helpers.

Helpers the flow imports. A file or directory whose name starts with _ is not a flow. It is somewhere to keep code the flows beside it import. Nor is one whose name starts with ., such as the ._twice.py a macOS archive leaves behind.

The rest of @flow.

KeywordDoes
name=Calls it this instead of the function's name. Letters, digits, _, . and -.
description=Shows this beside it instead of the docstring's first line.
hidden=TrueLeaves it out of the lists at the prompt. It still runs by name.
resumable=TrueLets a stopped run be picked up where it was, with ctx.state. See Loops.

One module can hold several flows, run as <directory>:<name>. See A flow that calls a flow.

Pitfalls ​

Agents run with approvals bypassed

Nobody is asked before an agent edits a file or runs a command. What it may touch is what its role's Permission says, and unless you say otherwise that is writing the directory it works in. See Permissions.

  • A turn you do not await never runs. builder.run(…) without await makes a coroutine and drops it. Python warns coroutine 'AgentView.run' was never awaited, and the agent is never asked. The test above fails on it: builder.prompts is one prompt short.
  • A new spawn forgets. Each spawn is a new conversation. Keep the session in a variable for as long as the agent should remember.
  • The role's name is the contract. -a builder=…, agents["builder"] and the test's agents={"builder": …} must agree. Renaming the role breaks every command line that runs the flow.

Next steps ​

Everything else is added to a flow like this one, a piece at a time:

ToAddRead
hold the agent to your test suite, and have a second agent reviewa shell on the workspace, a reviewer role, a loopBuild under test, the next page
start every round from nothing, or keep one conversation goingwhere spawn sits in the loopLoops
take settings from -p and a form at the prompta FlowParams subclassParams of its own
get an answer your code can branch onoutput_schema= a pydantic modelAnswers in a shape
keep an agent from writinga Permission on its rolePermissions
hand an agent skills_skills on its roleSkills
use a CLI's own /goala mixin on the roleGoals
have several turns going at onceasyncio.gatherMany turns at once
build on a flow somebody else wroteload(…)A flow that calls a flow
let whoever runs it say where the work lands, even another machinea role typed Env instead of LocalEnv, given with -eRemote execution

Every argument, refusal and return of the flow API is in Reference › Flows.

Released under the Apache-2.0 licence.