Skip to content

Flows ​

The flow API: the module hmz.flows, the command-line specs that fill a flow's roles (-a, -e, -p), how a flow is found, loaded, called, resumed and tested, and every exception it raises. Guides: Writing a flow, Loops.

Synopsis ​

python
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:
    """Two passes: 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("Review what you did; fix anything wrong.", session=session, env=workspace)
sh
hmz exec -f twice -a builder=claude/claude-opus-5:high -p budget.cost=5 "add a --dry-run flag"

Stability ​

ModuleStatus
hmz.flowsStable. Importing it loads pydantic and no other part of Humanize.
hmz.sdk, hmz.sdk.fakes (as from hmz.sdk import fakes)Stable. See SDK and Testing a flow.
hmz.runtime, hmz.runtime.flowingInternal. May change in any release. Named on this page only where a behaviour is defined there.

Agents, environments, sessions, the context and the state are protocols. At run time a flow is handed the runtime's own objects (views), which answer to these protocols structurally, hold __slots__, and derive from none of them. A view grants exactly what its role declared: anything else raises CapabilityNotGranted whatever the harness or machine underneath could do.

Every name ​

Everything below is importable from hmz.flows and listed in hmz.flows.__all__.

GroupNames
Defining a flowflow, Flow, FlowFn, load, FlowParams, FlowContext, FlowState
AgentsAgentCollection, Agent, Session, Outworlder, HarnessKind, HARNESS_AGENTS
Harness protocolsClaudeCodeAgent, CodexAgent, CursorAgent, OpenCodeAgent, MiMoCodeAgent, MiniMaxCodeAgent, QwenCodeAgent, KimiCodeAgent, GrokBuildAgent, PiAgent, AntigravityAgent, DeepSeekHarnessAgent, LiteLLMAgent (table)
Fake tools and command linesFakeTool, FakeCli, FakeCommand: hmz.coganchor.faking's own, re-exported
Agent mixinsFakeToolAgentMixin, GoalCommandAgentMixin, LoopCommandAgentMixin, SteeringAgentMixin, PermissionRequestHookAgentMixin, SubagentStartHookAgentMixin, SubagentStopHookAgentMixin, AskUserHookAgentMixin (table)
PermissionsPermission, PermissionKind
BudgetsBudget, Graceful, Usage
EnvironmentsEnvCollection, Env, LocalEnv, EnvBackendKind, SequenceNotStr
Environment mixinsShellEnvMixin, BashEnvMixin, FilesEnvMixin (table); GitWorktreeEnvMixin, TemporaryClonedDirEnvMixin, ScratchDirEnvMixin (table); RewindableEnvMixin, GitEnvMixin (table); CPUEnvMixin, MemoryEnvMixin, GPUEnvMixin, ImageEnvMixin (table)
HooksHookKind, HookFn, HookParams, HookResult, HOOK_TYPES, and eleven <Moment>HookParams/<Moment>HookResult pairs (table)
ErrorsFlowException and the 45 classes under it

Defining a flow ​

flow ​

python
def flow(
    *,
    agents: type[AgentCollection],
    envs: type[EnvCollection],
    params: type[FlowParams],
    name: str | None = None,
    description: str | None = None,
    hidden: bool = False,
    resumable: bool = False,
) -> Callable[[FlowFn], Flow]
ParameterTypeDefaultMeaning
agentstype[AgentCollection]requiredThe agent roles, one key per role. See AgentCollection.
envstype[EnvCollection]requiredThe environment roles, one key per role. See EnvCollection.
paramstype[FlowParams]requiredThe params model; FlowParams itself for none.
namestr | Nonethe function's __name__The flow's name in its module: the part of a ref after :. Must match [A-Za-z0-9_][A-Za-z0-9_.-]*.
descriptionstr | Nonefirst line of the docstring, else NoneOne line, shown in lists of flows.
hiddenboolFalseOmit from every list and from /flow. The flow still runs and loads by its ref.
resumableboolFalseKeep a journal; gives the flow a ctx.state. See Resumable flows.

Returns a decorator. Applied to an async def, the decorator returns a Flow (the runtime's FlowImpl). The decorator captures the global and local namespaces of the code that applied it.

Raised by the decorator (FlowDefinitionError, message prefixed with the function's qualified name):

ConditionMessage
not a coroutine functiona flow is an `async def` function
signature unreadableits signature cannot be read
no positional first parametera flow takes the task as its first argument
agents, envs, params or ctx not accepted as keyword (and no **kwargs)a flow takes `<name>` as a keyword argument
another parameter without a default `<param>` has no default, and a flow is called with a task, agents, envs, params and ctx alone
agents not an AgentCollection subclassagents=<value> is not an AgentCollection subclass
envs not an EnvCollection subclassenvs=<value> is not an EnvCollection subclass
params not a FlowParams subclassparams=<value> is not a FlowParams subclass
params with a field or alias budget'budget' is the run's budget, given as -p budget.<limit>=, and cannot be a param
name not matching the pattern'<name>' is not a flow name: letters, digits, `_`, `.` and `-`

Raised later, the first time the flow is called or described (listed, loaded by hmz exec, opened in /flow), when the collections' annotations are resolved against the captured namespaces (FlowDefinitionError; the first four rows are prefixed <Collection>.<role>, the rest with the role type's __qualname__, e.g. Builder: …):

ConditionMessage
annotation is a string that does not resolve'<text>' cannot be resolved: <error>
annotation is not a class<value> is not a class; a role is typed as one agent or environment type
agent role type not an Agent<type> is not an Agent
environment role type not an Env<type> is not an Env
agent type carries an environment mixinan agent cannot be declared with an environment's mixins
agent type derives from two harness protocolsno agent is both <a> and <b>
Outworlder type with a harness protocol or mixinan Outworlder is whoever is outside the run, and has no harness or mixins
_permission not a Permission<Type>._permission is not a Permission
_skills not a tuple/list of non-empty strings<Type>._skills is not a tuple of skill names
_tools not a tuple/list of FakeTool<Type>._tools is not a tuple of FakeTool
two of _tools of one name<Type>._tools names one tool twice
_tools without FakeToolAgentMixin<Type>._tools needs FakeToolAgentMixin
_clis not a tuple/list of FakeCli<Type>._clis is not a tuple of FakeCli
two of _clis of one name<Type>._clis names one command line twice
_clis on a LiteLLMAgent or an Outworlder<Type>._clis needs an agent with a shell
environment type carries an agent mixinan environment cannot be declared with an agent's mixins
_cpu_count, _memory, _gpu_count, _gpu_memory not an int ≥ 0 (a bool is refused)<Type>.<attr> is not a whole number of at least 0
_image not a string without whitespace<Type>._image is not an image: <value>

NotRequired, Required, ReadOnly and Annotated wrappers are read through. from __future__ import annotations, string annotations, and collections declared inside a function all resolve. A role's type is read once per type and cached.

Two flows with one name defined in one module raise FlowDefinitionError (<module dir>: two flows are called '<name>') when the module's flows are enumerated. Two functions of one Python name are one flow: the later binding replaces the earlier.

FlowFn ​

python
class FlowFn[TAgents: AgentCollection, TEnvs: EnvCollection, TParams: FlowParams](Protocol):
    async def __call__(
        self, task: str, *, agents: TAgents, envs: TEnvs, params: TParams, ctx: FlowContext
    ) -> Any: ...

The decorated function. Its return value is the flow call's return value: what a calling flow receives, Run.result, and what run_fake returns.

ArgumentValue at run time
taskstr, as given to hmz exec, /flow, Hmz().run or the caller
agentsa dict[str, AgentView | OutworlderView], one entry per role given or filled; a NotRequired role nobody gave is absent
envsa dict[str, EnvView], likewise
paramsan instance of the params model
ctxthe call's FlowContext

Flow ​

What @flow and load return.

MemberType
descriptionstr | NoneIts one line.
expected_agentstype[AgentCollection]The agent roles it declares.
expected_envstype[EnvCollection]The environment roles it declares.
expected_paramstype[FlowParams]Its params model.
resumableboolWhether a run of it can be picked up.
await flow(task, *, agents, envs, params, budget=None)AnyCall it from inside a run. See Calling a Flow.

A flow returned by load for a git+ ref is fetched when any of these members is first read or it is first called.

FlowParams ​

python
class FlowParams(pydantic.BaseModel):
    model_config = ConfigDict(extra="forbid", ser_json_inf_nan="strings")

Subclassed with one pydantic field per param. The model is also the form /flow renders: each field is a question, its type decides the input, its description is the help line.

Rule
Unknown keysRefused (extra="forbid").
budgetNot a field or alias: it is the run's, given as -p budget.<limit>=.
Field without a defaultMust be given (-p, /flow, or a caller).
Cross-field constraintsBelong in a model_validator, so they are refused before the run.
Infinity/NaNSerialized as the strings "Infinity"/"NaN".

How params are read (FlowImpl.params_of), in order:

  1. An instance of the flow's own model is used as is.
  2. Any other pydantic.BaseModel is dumped and validated into the model.
  3. A mapping is validated. If validation fails, each value whose field failed and which is a str is parsed as JSON (a string that is not JSON stays a string), and the mapping is validated again. This is how -p strings reach typed fields: -p rounds=5 → 5, -p tags='["a","b"]' → a list.
  4. Anything else raises ParamsError: <ref>: params=<value> is not a <Model> or a mapping.

A validation failure raises ParamsError (<ref>: <pydantic error>); from hmz exec it is refused before anything runs.

FlowContext ​

python
class FlowContext(Protocol):
    flow: Flow             # read-only properties
    budget: Budget
    usage: Usage
    state: FlowState | None
    resumed: bool
PropertyTypeValue
flowFlowThe flow being called.
budgetBudgetThe effective budget of this call; see Effective budget. Built on each read.
usageUsageWhat this call and every call under it has spent. Built on each read.
stateFlowState | NoneThe call's state for a resumable flow, None otherwise.
resumedboolTrue where this call picked up a call of an earlier run.

Each call has its own context. A flow that calls another ten times holds ten contexts; the spending of each is also counted in every context above it.

FlowState ​

python
class FlowState(Protocol):
    def __getitem__(self, key: str) -> Any: ...
    def __setitem__(self, key: str, value: Any) -> None: ...
    def __delitem__(self, key: str) -> None: ...
    def __contains__(self, key: str) -> bool: ...

ctx.state of a resumable flow. The runtime's object also implements __iter__, __len__ and get(key, default=None).

OperationBehaviour
state[key] = valuekey must be a str, else StateNotSerializable (a state key is a string, not <key>). value is serialized with json.dumps(..., allow_nan=True); a value it cannot serialize (or a recursion error) raises StateNotSerializable (also a TypeError): <type> cannot be kept in a flow's state: <error>. What is stored is the JSON round-trip of the value (a tuple becomes a list, dict keys become strings), so a fresh run and a resumed run read identical values. The write is appended to the journal and flushed before __setitem__ returns.
del state[key]Removes the key; KeyError if absent. Journaled and flushed.
state[key]KeyError if absent. A session kept in the value is read back as a new session each time.
Mutating a value in placeNot recorded. Assign the value again to record it.

A resumable flow run without a journal (a resumable flow called from a non-resumable run, or run_fake without journal=) gets a state held in memory only.

Sessions in state ​

A Session may be written into the state, alone or anywhere inside a value. The write keeps its conversation as it stands then: the runtime copies that conversation's own files, and nothing else of the CLI's home, into sessions/<cli>/.kept/<hex>/ in the epic, beside that CLI's sessions (a temporary directory removed with the run where there is no journal). Every read of the key answers a new session of the call's agent of the same role, whose first run carries the copy on as a fork: from where it stood when written, whatever the original session did afterwards. This holds in the run that wrote it and in every run picking it up, so one kept session can be carried on by several later runs, each a run of its own. A session whose turns work on another machine is kept and carried on the same way while its CLI runs here (harness local), supervised in a mirror of that machine's work; one whose CLI runs on another machine (harness env or a harness runtime) keeps its conversation there, and is refused as below. So is a harness that forks only in place, where that mirror is apart from the work: a container's or a job's, named for it, unlike an ssh host's.

python
session = await coder.spawn()
await coder.run(task, session=session)
ctx.state["boundary"] = session          # kept as it stands now
await coder.run("go on", session=session)

# here, or in a later run picking this one up:
again = ctx.state["boundary"]            # a new session, carrying on the kept conversation
await coder.run("carry on", session=again, env=envs["repo"])

Refused by the write, with StateNotSerializable and nothing written down:

SessionMessage
has taken no turn<role>: the session to keep has taken no turn to carry on from
a turn of it is under way, or it is over<role>: a turn of the session to keep is under way / <role>: the session to keep is over
of an agent another flow call was handed<role>: a session is kept only in the state of the flow call it is of
an outworlder'san outworlder's session cannot be kept in a flow's state
its harness does not fork (cursor-agent, mcode, agy, dsh)<role>: <harness> cannot fork a session (worded by the CLI's driver)
its harness keeps conversations as database rows (opencode, mimo)<role>: … (the conversation cannot be found as files)
its last turn's CLI ran on another machine: the machine's own, or under a harness put elsewhere<role>: <harness> keeps a conversation on another machine, not here

Refused by the read-back session's first run, before the CLI starts or anything is copied:

ConditionRaises
the role's agent is now another harnessUnsupportedOperation: <harness> cannot carry on a conversation <harness> kept
the turn's env is on another machine and its CLI would run there: the machine's own, or under a harness put elsewhereUnsupportedOperation: <harness> cannot carry a kept conversation onto another machine its CLI runs on
the turn's env is a container or a job (docker, swarm, slurm, apple-container), its CLI run here in a mirror apart from the work, on a harness that forks only in placeUnsupportedOperation: <harness> cannot carry a kept conversation into a mirror of another machine's work
the copy is gone (its epic deleted)SessionError: the session cannot be forked: <harness>: no conversation <id> under <dir>
the run already holds a different copy of the conversationSessionError: the session cannot be forked: <harness>: another copy of conversation <id> is kept at <path> already, which carrying this one on would replace
a harness that forks only in place, given another workdir than the conversation was had inrefused by the CLI

The last-but-one is what a run that keeps no sessions of its own meets: with HUMANIZE_SESSIONS=off, or for a CLI on macOS that cannot be linked, the conversation lives in the CLI's own home, and once the original session has gone on past the kept point, the home holds a later copy of it that is never put back. Everywhere else each run keeps its sessions in its own epic and this does not arise.

A session read back and written again before its first turn is written down as what it carries on, copying nothing.

Agent roles ​

AgentCollection ​

python
class AgentCollection(TypedDict, extra_items=ReadOnly[Agent]): ...

Subclassed with one key per role. The key is the role's name in -a <role>=…, in /flow, in the epic (agents, opened.agent), in the trace, and in the remembered settings.

Role typeFilled byRequired
Agent, a subclass with mixins, or a harness protocol-a, /flow, Hmz().run(agents=…), or a calleryes, unless NotRequired[...]
Outworlderthe runtime (the person at the prompt); naming it with -a is refusednever given

"<role>" in agents tests whether a NotRequired role was given.

Agent ​

python
class Agent(Protocol):
    _permission: ClassVar[Permission] = Permission()
    _skills: ClassVar[tuple[str, ...]] = ()
    _tools: ClassVar[tuple[FakeTool, ...]] = ()
    _clis: ClassVar[tuple[FakeCli, ...]] = ()

    role: str                     # read-only properties
    harness: HarnessKind
    model: str
    effort: str
    provider: str

    async def spawn(self) -> Session: ...
    async def run(self, prompt: str, *, session: Session, env: Env | None = None,
                  output_schema: type[M] | None = None,
                  budget: Budget | None = None) -> str | M: ...
    async def fork(self, session: Session) -> Session: ...
    def derive(self, *, permission: Permission | None = None,
               skills: tuple[str, ...] | None = None) -> Self: ...
    def on_session_start(self, fn: HookFn | None) -> None: ...
    def on_user_prompt_submit(self, fn: HookFn | None) -> None: ...
    def on_pre_tool_use(self, fn: HookFn | None) -> None: ...
    def on_notification(self, fn: HookFn | None) -> None: ...
    def on_stop(self, fn: HookFn | None) -> None: ...
    def on_session_end(self, fn: HookFn | None) -> None: ...
Class attributeTypeDefaultMeaning
_permissionPermissionPermission()What the role's sessions may touch; they run under exactly this. See Permissions.
_skillstuple[str, ...]()Skills given to the role's sessions. See Skills.
_toolstuple[FakeTool, ...]()The flow's functions the role's sessions offer the agent as tools; needs FakeToolAgentMixin. See Fake tools.
_clistuple[FakeCli, ...]()The flow's command lines the role's sessions put on the agent's PATH; any harness but litellm. See Fake command lines.
PropertyTypeValue
rolestrThe key it fills.
harnessHarnessKindThe CLI.
modelstrThe model, as written in -a.
effortstrThe effort in the CLI's own words; "" where -a said auto.
providerstrThe account its turns run as; "" for the account this machine's CLI is signed into.
MethodSection
spawn, run, forkSessions and turns
deriveAgent.derive
on_*Hooks

HarnessKind ​

class HarnessKind(StrEnum): which CLI an agent is -- or LITELLM, a model called directly. The value is the name -a uses.

MemberValueCLIProtocol (HARNESS_AGENTS[kind])
CLAUDEclaudeClaude CodeClaudeCodeAgent
CODEXcodexCodexCodexAgent
CURSOR_AGENTcursor-agentCursor AgentCursorAgent
OPENCODEopencodeopencodeOpenCodeAgent
MIMOmimoMiMo CodeMiMoCodeAgent
MCODEmcodeMiniMax CodeMiniMaxCodeAgent
QWENqwenQwen CodeQwenCodeAgent
KIMIkimiKimi CodeKimiCodeAgent
GROKgrokGrok BuildGrokBuildAgent
PIpipiPiAgent
OMPompOh My PiOhMyPiAgent
AGYagyAntigravityAntigravityAgent
DSHdshDeepSeek HarnessDeepSeekHarnessAgent
LITELLMlitellmnone: a model called through litellm, one chat completion a turn over the session's history, with no tools and no filesystem; its turns take env=NoneLiteLLMAgent
ACPacpany CLI added on the Accounts page of /settings, driven over the Agent Client Protocol; -a names it by the name it was added underAgent

HARNESS_AGENTS: Mapping[HarnessKind, type] (a read-only mapping) maps each kind to its protocol. The capabilities the runtime's driver of a harness serves are read off this protocol's bases, so the protocol and the driver cannot disagree.

Agent mixins ​

A role declares what it needs beyond Agent by subclassing mixins. A mixin's own bases come with it.

python
class Builder(Agent, GoalCommandAgentMixin, PermissionRequestHookAgentMixin):
    _permission = Permission(system=PermissionKind.NONE)
MixinGrantsWithout it
GoalCommandAgentMixinrun of a prompt that is /goal or starts /goal + whitespace: the harness's own goal command pursues the objective until the harness decides it is met. The driver strips the /goal prefix and starts a goal. See Goals.CapabilityNotGranted: <role>: /goal needs GoalCommandAgentMixin on the role
LoopCommandAgentMixinrun of /loop <interval> <task>, passed to the CLI as typed: its own recurring task.<role>: /loop needs LoopCommandAgentMixin on the role
SteeringAgentMixinsteer.<role>: steer needs SteeringAgentMixin on the role
PermissionRequestHookAgentMixinon_permission_request.<role>: a permission_request hook needs PermissionRequestHookAgentMixin on the role
SubagentStartHookAgentMixinon_subagent_start.<role>: a subagent_start hook needs SubagentStartHookAgentMixin on the role
SubagentStopHookAgentMixinon_subagent_stop.<role>: a subagent_stop hook needs SubagentStopHookAgentMixin on the role
AskUserHookAgentMixinon_ask_user.<role>: a ask_user hook needs AskUserHookAgentMixin on the role
FakeToolAgentMixin_tools.FlowDefinitionError: <Type>._tools needs FakeToolAgentMixin

The prefix test is on the prompt with leading whitespace removed. Every prompt not starting /goal or /loop goes to the CLI unchanged; other slash commands are not interpreted.

Fake tools ​

python
@dataclass(frozen=True, slots=True)
class FakeTool:
    name: str
    about: str
    call: Callable[..., object]
    takes: type[pydantic.BaseModel] | None = None

Each of a role's _tools is offered to the agent, by name and about, as a tool taking takes's JSON Schema, over MCP. The agent calling it runs call -- sync or async, given an instance of takes, or nothing where takes is None -- in the flow's process, on its loop, as the flow; the turn waits for it. A str it returns is what the agent reads, None reads done, anything else JSON. What it raises is answered to the agent as the tool failing; the turn and the flow go on. derive keeps a view's tools; a flow handed the agent offers its own role's. Served where the harness's row below has Fake tools (not acp: its protocol is a plain Agent, which serves no mixin), where the CLI runs on this machine (harness local); a turn of one whose harness is env or a harness runtime is refused. On grok, a turn only grok -p can take (an output_schema, a fork's first turn, web search off, a rung that takes tools away) is refused with HarnessUnrecoverable while tools are offered. See A tool of the flow's own.

FakeTool is the type coganchor's drivers offer (SessionBase.offers), so a flow's tool is handed to the CLI as it was declared, with only its call wrapped to run as the flow.

Fake command lines ​

python
@dataclass(frozen=True, slots=True)
class FakeCommand:
    name: str
    about: str
    call: Callable[..., object]
    takes: type[pydantic.BaseModel] | None = None

@dataclass(frozen=True, slots=True)
class FakeCli:
    name: str                          # a file name: letters, digits, `.`, `_`, `-`
    about: str
    commands: tuple[FakeCommand, ...]  # at least one, each named once
    def run(self, argv: Sequence[str]) -> tuple[int, str, str]: ...

Each of a role's _clis is a program named name on the PATH of every process of the agent, answered by an argparse parser built from each command's takes: a --flag per field, its description the help, required where it has no default. --help prints the help and exits 0; a line the parser or the model refuses prints the usage and exits 2. Running a command runs call -- sync or async, given an instance of takes, or nothing where it is None -- in the flow's process, as the flow: a str it returns is printed, None prints nothing, anything else JSON; SystemExit(n) exits n, anything else raised exits 1 with the error on stderr. No mixin is needed: every harness with a shell serves it, and a role declaring any is refused for litellm: <ref>: '<role>' declares _clis, and litellm has no shell to run them from hmz exec or as CapabilityMissing before the run starts, and UnsupportedOperation at run for one a caller hands on, as an env is. FakeCli is the type coganchor's drivers shelve (SessionBase.shelves). See A command line of the flow's own.

What each harness serves ​

Generated from hmz.runtime.flowing.spi.HARNESS_CAPABILITIES and the harness profiles.

-aGoalLoopSteeringPermission requestSubagent start, stopAsk userFake toolsForkRungs
claude✓✓✓✓✓✓✓any workdirread-only, workspace-write, auto, bypass
codex✓✓✓✓✓✓any workdirread-only, workspace-write, auto, bypass
kimi✓✓✓✓any workdirread-only, workspace-write, auto, bypass
pi omp✓✓✓same workdirread-only, workspace-write, auto, bypass
dsh✓✓nobypass
litellmany workdirread-only, workspace-write, auto, bypass
cursor-agent✓noread-only, workspace-write, auto, bypass
mcode✓noworkspace-write, auto, bypass
grok opencode mimo qwen✓same workdirread-only, workspace-write, auto, bypass
agy✓noread-only, workspace-write, auto, bypass
acpsame workdirset by the CLI

A role typed as a harness protocol asks for that harness and every mixin in its row. Given another harness it is refused: from hmz exec as <flow>: '<role>' requires <harness>, but got <other> before anything runs, from a calling flow as HarnessMismatch. A role declared by mixins alone may be filled by any harness that serves them.

Where capabilities are checked ​

WhenCheckRefusal
Before a run (hmz exec, Hmz().run, Runner)Each role's harness serves its mixins; a harness-protocol role gets that harness; required roles given; no role given that the flow does not declare or that the runtime fills.Refused (a ValueError), exit status 2 from hmz exec; see Command-line refusals
When run_flow starts the top callThe same, against the drivers.HarnessMismatch, CapabilityMissing, MissingRole, RequirementError
When a flow calls anotherWhat the caller hands over covers the callee's declaration.RequirementError leaves; see What the called flow is handed
At every useThe view's grant contains the mixin for the operation.CapabilityNotGranted

Of the flows Humanize ships in the package, only chat is marked full view: each agent role is granted every mixin its harness serves, whatever it declares.

Permissions ​

Approvals are bypassed

Every harness runs in its no-approval mode. No session waits for a person to approve a tool, and no model reviews another model's actions. An agent is limited by its Permission and by the hooks the flow hangs on it.

Permission ​

python
@dataclass(frozen=True, slots=True)
class Permission:
    local: PermissionKind = PermissionKind.ALL
    user: PermissionKind = PermissionKind.READ
    system: PermissionKind = PermissionKind.READ
    online: PermissionKind = PermissionKind.ALL

    def covers(self, other: Permission) -> bool: ...
FieldScopeDefaultAllowed
localthe workdir of the environment the turn works inALLNONE, READ, ALL
userthe rest of the home directory of the user the CLI runs asREADNONE, READ, ALL; ≤ local
systemeverything else on the machineREADNONE, READ, ALL; ≤ user
onlinethe network, including the CLI's own web search and fetchALLNONE, ALL

Construction coerces each field with PermissionKind(value) and raises ValueError:

ConditionMessage
field not a permission kind<field>=<value> is not a permission kind
local >= user >= system failsa wider scope may not be granted more than a narrower one: local=<k>, user=<k>, system=<k>
online is READonline is all or nothing: NONE or ALL, not READ

covers(other) returns True iff each of the four fields is >= the same field of other.

PermissionKind ​

class PermissionKind(StrEnum): NONE = "none", READ = "read", ALL = "all", totally ordered NONE < READ < ALL (<, <=, >, >= defined; comparing with anything but these three strings raises TypeError).

Enforcement ​

A permission reaches a session twice: as a fence around the CLI's process tree, and as a rung of the CLI's own approval ladder.

The fence. system is /, user is the home directory, local is the workdir. READ allows reading a scope, ALL reading and writing, NONE neither. local ALL also writes /tmp, /var/tmp and an absolute $TMPDIR, as Codex's workspace-write does. user ALL writes whatever the user may change: all of /, held by the files' own permissions, except for root, which gets those temporary directories and /run/user/0 and keeps the rest at system. Independently of the scopes, the CLI may:

  • read /usr, /bin, /lib*, the files under /etc a resolver and TLS stack read, /sys (on macOS /System and /private/var/select instead of /lib* and /sys), its own programs and install trees, and the Python running Humanize;
  • write the device nodes (/dev/null, /dev/tty, /dev/pts, /dev/shm, GPU and RDMA nodes; on macOS /dev/ttys*), /proc on Linux (CUDA names a thread through /proc/self/task/<tid>/comm as it starts), its own state and sign-in directories (on macOS also the login keychain, ~/Library/Keychains), the directory its sessions are kept in, and a private scratch directory that is its TMPDIR. XDG_CACHE_HOME, UV_CACHE_DIR, npm_config_cache, PIP_CACHE_DIR and GOCACHE are pointed into that directory where the fence would not let them be written.

online NONE cuts the network except to the hosts the CLI's model and sign-in are at (read under the session's account, so a gateway the account points at is included). The default permission is a real fence: the workdir, the temporary directories and the minimum above are writable, the rest readable.

A CLI that can enforce part of the fence itself is configured to. The rest is enforced from outside by hmz internal fence: on Linux, Landlock for paths and TCP, a seccomp filter refusing every other socket family and a second filter that lets a socket listen on loopback only; on macOS, a Seatbelt profile for paths and sockets; and on both, a loopback proxy passing only the listed hosts. A session is never run wider than its permission. Where neither the CLI nor the machine can hold the fence, the session is refused with HarnessSandboxed when it opens:

CauseRefused where
no Landlock (Linux < 5.13), or macOS where Humanize already runs inside a sandboxany fence other than all four scopes ALL with online ALL
Linux < 6.7online NONE
wrapper cannot trace its children (a container's default seccomp profile; Yama ptrace_scope 2 or 3)online NONE

local, user, system all ALL with online ALL fences nothing, and so does userALL with online ALL for anyone but root.

With the network cut on Linux, a program may listen on loopback only (127.0.0.0/8, ::1, IPv4 loopback mapped into IPv6). A bind elsewhere, and a listen on a socket bound elsewhere, fail with EACCES; the wrapper performs the listen itself on the socket it checked. A process that makes itself undumpable (ssh-agent) cannot be checked and cannot listen. On macOS, Seatbelt cannot tell loopback from every address when a socket is bound, so a program may listen on any address; it still connects to nothing but the proxy.

Gaps remain, all in the kernel: neither Landlock nor Seatbelt governs connecting to a Unix socket (a docker daemon's or a session bus's is reachable; on macOS so is a system service over Mach), on Linux with the network cut the proxy's port is reachable by number on any address, and on macOS a program may listen on any address, as above.

When the work is on another machine (a docker or ssh environment) the fence is held on both: the CLI here inside this machine's Landlock with the environment's mirror as its workdir, and every command on the target under hmz internal fence again, around the target's own workdir, $HOME and minimum. With online NONE a command on the target reaches no host. A CLI's own sandbox rung is not used there. A target that cannot hold the fence refuses the session with HarnessSandboxed on its first turn; a container under docker's default seccomp profile can hold online ALL but not NONE. See Remote execution.

CLIFilesystemNetwork
claudeexternal; its own sandbox covers only its Bash tool and is not usedexternal; WebSearch, WebFetch refused by rule
codexexternal; on macOS its own sandbox, which cannot start inside the fence, is told the fence holds its commandsexternal; web search, ChatGPT apps and the system proxy off natively; at local READ with online ALL its read-only sandbox is told to leave commands the network
cursor-agentexternal; its own sandbox covers only shell commands and needs a user namespaceNONE refused: its web tools run on Cursor's servers
mcodeexternal; no sandbox of its ownNONE refused: its web search runs on MiniMax's service
opencode, mimoexternal; file tools also refuse outside the fenceexternal; web tools removed offline
qwenexternalexternal; web_search, web_fetch withheld
kimiexternalexternal; its daemon may bind its one port
grokexternal; its own sandbox writes /tmpexternal; --disable-web-search offline
piexternalexternal; started --offline; gateways its models.json declares stay reachable
ompexternalexternal; never started --offline; the baseUrl of the model's provider in its models.yml stays reachable
agyexternal; its --sandbox covers only commandsexternal; web tools removed offline (and always with online NONE)
dshexternalexternal
acpexternal, plus the state declared for itexternal, to the hosts declared for it; NONE with none declared refused

"External" is Landlock (Seatbelt on macOS) plus the loopback proxy.

The rung.

localevery harness but dsh, mcode, acpdsh, mcode, acp
READ or NONEread-only (Claude Code plan, Codex read-only sandbox, a tool list without writers)bypass
ALLbypassbypass
  • bypass is the CLI's no-approval mode: Codex danger-full-access with approval never; Claude Code bypassPermissions. Where a managed policy forbids that, the CLI runs at the most permissive mode left that no model reviews and Humanize answers every request yes: Claude Code acceptEdits, Codex the workspace-write sandbox with approval on-request.
  • A hook only an asking CLI can deliver changes how it is started. Claude Code runs at manual instead of bypassPermissions while on_permission_request is hung. Codex runs with approval policy untrusted while on_permission_request is hung, and enables its default_mode_request_user_input feature while on_ask_user is hung. Kimi Code runs at coganchor's auto rung (Kimi's yolo) while either is hung. Humanize answers yes unless the hook says no.
  • online also switches the CLI's own web tools (on for ALL, off for NONE) where the CLI can be told; where it cannot (cursor-agent, mcode, pi, omp, agy, acp) the cut network stops them.

The rungs themselves are in Agents.

Agent.derive ​

python
def derive(self, *, permission: Permission | None = None,
           skills: tuple[str, ...] | None = None) -> Self
ParameterDefaultMeaning
permissionthis agent'sThe derived agent's permission. Must be covered by this agent's.
skillsall of this agent'sWhich of this agent's skills its sessions carry. Must be a subset.

Returns a new view of the same driver with the narrower grant; the original is unchanged. The derived agent shares the original's hooks and its sessions (a session of either may be used by the other).

ConditionRaises
permission not a PermissionTypeError: permission=<value> is not a Permission
permission widerCapabilityNotGranted: <role>: <permission> is wider than the <granted> this agent was granted
a skill not grantedCapabilityNotGranted: <role>: <names> is not among the skills this agent was granted

Sessions and turns ​

A session is one conversation of one agent: its history, and nothing of where it works. A turn is one prompt and its answer, and says where it works: run(…, env=…). One session may take its turns in different environments.

Agent.spawn ​

python
async def spawn(self) -> Session

Opens a session with no turns. Starts no CLI and settles nothing about where it works: its first turn does both, in the environment that turn is given.

ConditionRaises
the call's caller or the run has endedFlowCancelled

Agent.run ​

python
async def run(self, prompt: str, *, session: Session, env: Env | None = None,
              output_schema: type[M] | None = None,
              budget: Budget | None = None) -> str | M
ParameterTypeMeaning
promptstrThe prompt. /goal … and /loop … need their mixins.
sessionSessionA session of this agent (or of an agent derived from it).
envEnv | NoneWhere this turn works: its workdir, on its machine. None is the run's own workspace, the directory the run was started in, on this machine. Always None for litellm, which works nowhere.
output_schematype[pydantic.BaseModel] | NoneReturn an instance of this model instead of text.
budgetBudget | NoneA limit on this one turn, applied with every budget above it. See Budgets.

Returns the text of the agent's last message, or an instance of output_schema.

Where the turn works. The session's first turn starts its CLI in env, settling where its harness runs (see Harness placement). A later turn given the same workdir on the same machine carries on as before. One given another workdir on the same machine carries the conversation there, as a fork of itself cut by that turn, on Claude Code, Codex and Kimi Code; every other harness refuses it, and every harness refuses another machine. A refused turn leaves the session where it was, ready for a turn there.

Order of events in one turn:

  1. Capability check for /goal, /loop; budget check (a spent budget raises its BudgetExceeded leaf before anything is sent).
  2. The session's CLI is started in env (first turn) or the session is carried to env (later turns), as above.
  3. On the session's first turn only: SESSION_START. A non-empty context is prepended to the prompt, separated by a blank line.
  4. USER_PROMPT_SUBMIT with the prompt as given. block=True raises SessionError(reason) (a hook refused the prompt for an empty reason). A non-empty context is appended after a blank line.
  5. The CLI takes the turn. Steers queued before it starts are appended, each after a blank line.
  6. If no steer is pending: STOP with said and again. block=True with a non-empty reason sends reason as the next prompt of the same turn and increments again. An empty reason does not block.
  7. Steers that arrived during the CLI's turn are joined with blank lines and sent as the next prompt of the same turn.
  8. With output_schema, the final text is parsed (below).

Reading output_schema. The model is validated with model_validate_json against, in order: the whole answer stripped; each Markdown fenced code block, regardless of its info string (including JSON, jsonc and tilde fences); the span from the first { to the last }. The first that validates is returned. If none does: OutputSchemaError: the answer is not a <Model>: <first 200 chars, JSON-quoted>.

ConditionRaises
session not this agent'sSessionError: <role>: <session> is not one of this agent's
env not an environment the run handed outTypeError: <env> is not an environment this run handed out
env given to a litellm agentUnsupportedOperation: <role>: litellm is a model called directly, with no tools and no filesystem, so its turns work in no environment; run it with env=None
a turn of the session is under waySessionError: <role>: a turn of this session is under way
the session is closedSessionError: <role>: the session is over
the call's caller or the run has endedFlowCancelled
the agent's driver is closedSessionError: the agent's driver is closed
a local workdir that is not a directorySessionError: <dir> is not a directory to open a session in
CLI not installed where its harness runsHarnessNotInstalled
the fence cannot be heldHarnessSandboxed
the CLI cannot be configured as the session asksHarnessUnrecoverable
env on another machine than the session's last turnUnsupportedOperation: <harness> cannot move a session onto another machine
env elsewhere, on a harness that does not fork (cursor-agent, mcode, agy, dsh)UnsupportedOperation: <harness> cannot move a session
env another workdir, on a harness that forks only in placeUnsupportedOperation: <harness> cannot move a session into another workdir
a fork's first turn, its parent having taken a turn since the forkSessionError: <role>: the session it was forked from has taken a turn since; …
the turn was interruptedSessionError: the turn was interrupted
a budget over the turn is spentthe BudgetExceeded leaf
a hard (not graceful) limit reached mid-turnthe BudgetExceeded leaf; the CLI stops
the CLI failed the turnthe HarnessError leaf
the task running run is cancelledasyncio.CancelledError; the CLI is interrupted and waited on for up to 30 s

One session takes one turn at a time; two sessions of one agent may take turns concurrently.

SteeringAgentMixin.steer ​

python
async def steer(self, prompt: str, *, session: Session, queued: bool = True) -> None
ParameterDefaultMeaning
promptText put into the turn.
sessionThe session taking the turn.
queuedTrueTrue: the agent receives it at its next opportunity and continues. False: the CLI's current step is interrupted and the turn continues from this prompt (as esc then typing would).

Raises CapabilityNotGranted without the mixin, SessionError for another agent's or a closed session, and SessionError where the session has no turn under way.

Agent.fork ​

python
async def fork(self, session: Session) -> Session

Opens a new session that continues session's conversation; session is unchanged. The fork works wherever its turns are given: its first run cuts it, in that turn's env.

ConditionRaises
session not this agent's, or closedSessionError (<role>: the session to fork is over)
session has taken no turnSessionError: <role>: the session to fork has taken no turn to carry on from

What the harness cannot do is raised by the fork's first run:

ConditionRaises
harness does not fork (cursor-agent, mcode, agy, dsh)UnsupportedOperation: <harness> cannot fork a session
the turn's env on another machine than the parent's last turnUnsupportedOperation: <harness> cannot fork a session onto another machine
the turn's env another workdir, on a harness that forks only in placeUnsupportedOperation: <harness> cannot fork a session into another workdir
the parent has taken a turn since the forkSessionError: <role>: the session it was forked from has taken a turn since; …
the CLI cannot cut the forkSessionError: the session cannot be forked: <reason>

The parent is held open until the fork's first turn. See Branching a conversation.

Session ​

python
class Session(Protocol):
    agent: Agent       # read-only properties
    usage: Usage
PropertyValue
agentThe agent whose conversation this is.
usageWhat its turns have spent, current on every read.

A session holds no environment: each turn is given one.

Lifetime. There is no close. A session is closed at the earlier of:

  • the end of the flow call that opened it and of every call that call started; or
  • the moment nothing references its view (the engine holds views weakly; a view held only by a reference cycle is closed when the cycle collector finds it).

Closing runs on the run's event loop exactly once and fires SESSION_END. A fork keeps its parent open until the fork's first turn. A turn that is cancelled (a TaskGroup sibling failing, a deadline, ctrl+c) interrupts the CLI.

Hooks ​

A hook is an async function from one moment's params to that moment's result, hung with the agent's on_<moment> method.

python
async def not_yet(hook: StopHookParams) -> StopHookResult:
    left = "- [ ]" in (await workspace.read("TASK.md")).decode()
    return StopHookResult(block=left and hook.again < 5, reason="TASK.md has unticked boxes.")

agent.on_stop(not_yet)
MethodMomentNeedsDelivered
on_session_starta session's first turn is about to startby the driver, before the turn
on_user_prompt_submita prompt is about to be sentby the driver, before each turn
on_pre_tool_usethe agent reached for a toolfrom inside the CLI; see below
on_notificationthe agent stopped to tell its user somethingfrom inside the CLI
on_stopa CLI turn ended and the turn is about to endby the driver
on_session_endthe session is closingby the driver
on_permission_requestthe harness asks whether a tool may runPermissionRequestHookAgentMixinfrom inside the CLI
on_subagent_startthe agent started a subagentSubagentStartHookAgentMixinfrom inside the CLI
on_subagent_stopa subagent is about to finishSubagentStopHookAgentMixinfrom inside the CLI
on_ask_userthe agent asks its user a question mid-turnAskUserHookAgentMixinfrom inside the CLI
on_outworlder_runan outworlder made with Outworlder.new() is asked to take a turnan outworlder from Outworlder.new()by the runtime

Hook fields ​

Every params class also carries ctx: FlowContext (the context of the flow the agent belongs to) and session: Session (the session the moment arrived in). All params and results are @dataclass(frozen=True, slots=True, kw_only=True).

HookKindParams fieldsResult fields (default)
SESSION_STARTcontext: str = ""
USER_PROMPT_SUBMITprompt: strblock: bool = False, reason: str = "", context: str = ""
PRE_TOOL_USEtool: str, input: Mapping[str, Any]block: bool = False, reason: str = ""
PERMISSION_REQUESTtool: str, input: Mapping[str, Any]allow: bool = True, reason: str = ""
NOTIFICATIONmessage: str
STOPsaid: str, again: int = 0block: bool = False, reason: str = ""
SESSION_END
SUBAGENT_STARTsubagent: str, task: str = ""context: str = ""
SUBAGENT_STOPsubagent: str, said: str = ""block: bool = False, reason: str = ""
ASK_USERquestion: str, options: tuple[str, ...] = ()answer: str | None = None
OUTWORLDER_RUNprompt: str, output_schema: type[BaseModel] | None = Noneoutput: str | BaseModel (required)

The result classes are <Moment>HookResult, the params <Moment>HookParams (e.g. StopHookParams, StopHookResult). A result constructed with no arguments changes nothing.

FieldMeaning
tool, inputThe tool as the harness names it and its arguments; {} where the harness gives none. A tool known only from the CLI's output stream carries {"about": "<the transcript line>"}.
said, againThe agent's last text, and how many times a STOP hook has already kept this turn going.
subagent, taskThe harness's name for the subagent, and its task.
question, optionsThe question and the offered answers. answer need not be one of them; None leaves it unanswered and the agent continues without one.
contextText added to the prompt (see run). For SUBAGENT_START it is delivered to no CLI.
block, reasonSee run for USER_PROMPT_SUBMIT and STOP. A blocked PRE_TOOL_USE refuses the tool and tells the agent reason (refused by a hook if empty). SUBAGENT_STOP results are delivered to no CLI.
allow, reasonallow=False refuses the tool, overriding the bypassed approvals; the agent is told reason (on Codex, whose refusals carry no reason, as a steer into the turn).
outputWhat the outworlder's run returns: text, or an instance of output_schema.

Rules.

  • Hanging a hook replaces the one hung on that moment; None removes it.
  • A hook covers every session of its agent, including sessions opened later and sessions of agents derived from it. A called flow's hooks are its own: they are not heard by its caller's sessions and vice versa.
  • A hook runs on the flow's event loop as the flow its agent belongs to. An exception it raises fails the turn the moment arrived in; run raises it.
  • on_pre_tool_use can refuse a tool only on CLIs that gate tools through their own hook table (Claude Code, Qwen Code). On the rest it observes a tool already reached for.
  • A moment delivered from inside the CLI that waits more than 900 s (HOOK_TIMEOUT) for its hook is answered as if no hook were hung. on_ask_user has no timeout.
  • A hook hung mid-turn applies to that turn's later moments, except those that change how the CLI is started, which apply from the next turn: on_pre_tool_use on a gating CLI, and on_permission_request or on_ask_user on Codex (whose app server is restarted between turns for it) and Kimi Code.
  • A session whose CLI starts (its first turn, or a turn that carries it elsewhere) on another machine while on_pre_tool_use or on_permission_request is hung is refused where its runtime's harness is env, the CLI there being one these hooks cannot gate; see Harness placement.

HookKind ​

class HookKind(StrEnum): SESSION_START, USER_PROMPT_SUBMIT, PRE_TOOL_USE, PERMISSION_REQUEST, NOTIFICATION, STOP, SESSION_END, SUBAGENT_START, SUBAGENT_STOP, ASK_USER, OUTWORLDER_RUN. Each value is the lowercase member name (HookKind.STOP == "stop").

HookFn ​

python
class HookFn[TParams: HookParams, TResult](Protocol):
    async def __call__(self, params: TParams, /) -> TResult: ...

HookParams and HookResult ​

python
@dataclass(frozen=True, slots=True, kw_only=True)
class HookParams:
    ctx: FlowContext
    session: Session

@dataclass(frozen=True, slots=True, kw_only=True)
class HookResult: ...

The bases of every <Moment>HookParams and <Moment>HookResult.

HOOK_TYPES ​

HOOK_TYPES: Mapping[HookKind, tuple[type[HookParams], type[HookResult]]]: HOOK_TYPES[HookKind.STOP] == (StopHookParams, StopHookResult).

The person at the prompt ​

Outworlder ​

python
class Outworlder(Agent, Protocol):
    away: bool                                     # read-only property

    @classmethod
    def new(cls) -> Self: ...
    def on_outworlder_run(self, fn: HookFn | None) -> None: ...

A role typed Outworlder is filled by the runtime. At the top of a run it is whoever started the run; naming it with -a is refused (<flow>: '<role>' is assigned automatically by the runtime and cannot be set with -a).

MemberBehaviour
spawn()Opens a session of the outworlder.
run(prompt, session=…, env=None, output_schema=None)Asks the person and returns what they typed; with output_schema, asks one question per field and builds the model. A person works nowhere: env is checked and otherwise ignored.
awayTrue under hmz exec (no one is at a prompt) and while /afk is on for the role.
Outworlder.new()A new outworlder answered by the flow through on_outworlder_run. Away until a hook is hung.
on_outworlder_run(fn)Only on an outworlder from new(); on any other raises CapabilityNotGranted: <role>: only an outworlder made with Outworlder.new() is answered by a hook.
forkUnsupportedOperation: an outworlder's session cannot be forked.
derive(skills=…)Non-empty skills raise CapabilityNotGranted: <role>: an outworlder has no skills.

While away, run returns at once: "" for text; the schema built from defaults where every field has one; otherwise OutworlderAway: nobody is there to answer with a <Model>, and it has fields with no default.

A caller may pass its own outworlder, one from Outworlder.new(), or omit the role (the callee gets the run's). An outworlder may also be passed for a plain Agent role that declares no mixin and no harness; a role that does raises CapabilityMissing (<ref>: '<role>' asks for what no outworlder can do). The run's outworlder is asked, and asked whether it is away, as the Outworlder role the run filled with it, whatever role name a caller passes it under.

Environment roles ​

An environment is a working directory on a machine: this one, one reached with ssh, a container of its own on a docker daemon, on whichever node of a docker swarm has room or in Apple's container on this Mac, or the node of a job of its own on a Slurm cluster.

EnvCollection ​

python
class EnvCollection(TypedDict, extra_items=ReadOnly[Env]): ...

One key per role, typed Env, LocalEnv, or a subclass carrying mixins. NotRequired roles may be omitted. Every role but a LocalEnv one is filled with -e.

Env ​

python
class Env(Protocol):
    workdir: PurePosixPath      # read-only properties
    backend: EnvBackendKind
    provider: str
    available: bool
    role: str

    async def derive_subdir(self, *, subdir: PurePosixPath | str) -> Env: ...
MemberValue
workdirWhere commands run and relative paths resolve: absolute, or ~/… under the ssh login's home. For docker, a directory of the daemon's host, mounted at the same path in the container; for swarm, one of the node the task landed on, likewise; for apple-container, one of this Mac, likewise; for slurm, one of a shared filesystem, at the same path on the job's node and here.
backendlocal, ssh, docker, swarm, apple-container or slurm.
provider"" for local; the ssh destination or saved runtime name for ssh; the docker runtime name (local for docker's default here) for docker; the swarm runtime name (local for the swarm this machine manages) for swarm; the Apple container runtime name (local for this Mac's with nothing saved) for apple-container; the Slurm runtime name (local for the cluster's defaults) for slurm.
availableWhether the machine was reachable and the workdir existed, as last probed.
roleThe key it fills.
derive_subdir(subdir=…)An environment at a directory under this workdir, created if missing, same role and grant. ValueError for an absolute subdir or one that climbs out; EnvError if it cannot be made.

LocalEnv ​

python
class LocalEnv(Env, Protocol): ...

The workspace the run was started in (the directory hmz was started in, or the Hmz workspace), filled by the runtime. A role typed LocalEnv or a subclass takes no -e (<flow>: '<role>' is the workspace the run started in and cannot be set with -e). A caller may omit it (the callee gets the run's workspace, granted what the callee declares) or pass an environment on this machine; an environment on another machine raises CapabilityMissing: <ref>: '<role>' is a LocalEnv, and the environment given is <backend>[@<provider>]/<workdir>, which is not this machine.

EnvBackendKind ​

class EnvBackendKind(StrEnum): LOCAL = "local" (this machine), SSH = "ssh" (a host ssh reaches), DOCKER = "docker" (a container of its own on a docker runtime's daemon), SWARM = "swarm" (a container of its own as the one task of a service on a swarm runtime's swarm, on whichever node it was placed), APPLE_CONTAINER = "apple-container" (a container of its own, a small Linux virtual machine Apple's container runs on this Mac), SLURM = "slurm" (a job of its own on the Slurm cluster this machine submits to, on whichever node the cluster gave it).

What an environment can do ​

MixinAddsWithout it
ShellEnvMixinasync exec(argv: SequenceNotStr[str], *, timeout: float = 0) -> tuple[int, str, str]: runs one program, no shell, in the workdir. Returns (exit status, stdout, stderr) decoded as UTF-8. timeout in seconds, 0 for none; past it the program is killed and EnvCommandTimeout raised. EnvError if it cannot start.<role>: exec needs ShellEnvMixin on the role
BashEnvMixinexec(script: str, …) too, run as bash -c script in the workdir, same timeout and results. Includes ShellEnvMixin.<role>: a script exec needs BashEnvMixin on the role
FilesEnvMixinasync read(path: str) -> bytes, async write(path: str, data: bytes) -> None; path relative to the workdir or absolute. write replaces the file whole and creates parent directories. EnvFileNotFound for a missing file, EnvPermissionDenied for a refused one.<role>: read needs FilesEnvMixin on the role (or write)

exec takes exactly one command, positional or as argv=/script=; anything else raises TypeError: exec takes one command: an argv, or a script.

SequenceNotStr[T] is the type of an argv: a sequence that is not a str (a str's __contains__ takes only str, which keeps it from matching). Lists and tuples of strings match.

What a machine must have ​

MixinClass attributeDefaultMeaning
CPUEnvMixin_cpu_count: int1Minimum logical CPUs.
MemoryEnvMixin_memory: int0Minimum memory, bytes.
GPUEnvMixin_gpu_count: int, _gpu_memory: int1, 0Minimum GPUs; minimum memory per GPU, bytes.
ImageEnvMixin_image: str""Image a docker, swarm or apple-container environment's container starts from: "" for the provider's image, else python:3.12-slim. Needs /bin/sh and Python ≥ 3.12; no sshd. Ignored for local, ssh and slurm.

An attribute is read only where its mixin is among the type's bases. For local and ssh, a machine with less than declared is refused before anything runs with ResourceUnmet: <ref>: '<role>' needs <n> CPUs, and the environment given has <m> (likewise bytes of memory, GPUs, bytes of memory per GPU).

For docker the amounts size the container: it gets exactly what the role declares as hard limits, no limit on what it declares none of, and no GPU without GPUEnvMixin. Before any agent starts, the request is held against what the provider may still hand out; where it cannot, the run is refused with ResourceUnmet, naming what is free and which container holds the rest (how). Everything derived from a docker environment is in the same container.

For swarm the amounts are what the task reserves of its node, and its limits too; GPUs are reserved as the runtime's generic resource. A runtime without that much left, or a task no node of the swarm takes, is refused with ResourceUnmet the same way (how). Everything derived from a swarm environment is in the task's container.

For apple-container the CPUs and memory size the container's virtual machine, and a role declaring GPUEnvMixin is refused with ResourceUnmet, Apple's containers being given no GPU (how). Everything derived from an apple-container environment is in the same container.

For slurm the amounts are what the job asks for (--cpus-per-task, --mem, --gpus), which the cluster confines it to; nothing declared is asked for, leaving the partition's defaults. A runtime without that much left of its cpus, memory or max_jobs, or a job no node takes within its max_wait, is refused with ResourceUnmet (how). Everything derived from a slurm environment is on the job's node, under Humanize's home there.

Worktrees, copies and scratch directories ​

MixinMethods
GitWorktreeEnvMixinasync derive_worktree(*, ref: str | None = None, dir: PurePosixPath | str | None = None) -> Self
TemporaryClonedDirEnvMixinasync derive_temp_clone(id: str) -> Self, async destroy_temp_clone(id: str) -> None
ScratchDirEnvMixinasync derive_scratch(id: str) -> Self, async destroy_scratch(id: str) -> None

Each derived environment is on the same machine, fills the same role, and carries the same grant. GitWorktreeEnvMixin needs git on the machine's PATH: a flow declaring it on one without is refused before anything runs, as for GitEnvMixin.

MethodBehaviourRaises
derive_worktreegit worktree add of the repository the workdir is in, ref checked out detached (default: what the workdir has checked out), at dir (relative to the workdir or absolute; default a fresh directory under envs/…/worktrees/). Not removed by Humanize.WorktreeError: not in a repository, unknown ref, directory taken
derive_temp_clone(id)A copy of the workdir (a reflink where the filesystem supports it). Same id from the same environment → same copy, made once. Needs no git, except to copy a workdir that is a linked worktree.TempCloneBusy if another environment holds that id; EnvError if the copy fails
derive_scratch(id)An empty directory; same id → same directory.ScratchError
destroy_temp_clone(id), destroy_scratch(id)Remove now; no-op if absent.ScratchError (scratch)

Copies and scratch directories are removed when the flow call that made them ends (after every call it started), with at most 30 s allowed for cleanup, unless the run keeps a journal (a resumable run): then each is recorded in the journal and kept for --resume. Locations are under envs/ in Humanize's home on the machine that holds them; see Files.

Snapshots and rewinding ​

MixinMethods
RewindableEnvMixinasync snapshot(name: str | None = None) -> str, async rewind(ref: str) -> None, async snapshots() -> list[str]
GitEnvMixinthe same, implemented with git. Includes RewindableEnvMixin.

RewindableEnvMixin is an interface: declared alone it grants nothing, and each method raises CapabilityNotGranted (<role>: snapshot needs GitEnvMixin on the role). Type helpers against it and declare GitEnvMixin on the role.

MethodBehaviour
snapshot(name)Records the git worktree containing the workdir — HEAD, the index, and every file not ignored (untracked included) — as a commit under refs/hmz/snapshots/<name>, touching none of them. Returns that ref. name=None uses <UTC %Y%m%dT%H%M%S.%fZ>-<4 hex>. An existing name is replaced.
rewind(ref)Restores the worktree. A snapshot ref restores its commit, index and files (untracked as untracked). Any other ref git resolves to a commit is checked out with its files and a matching index. The checked-out branch (if any) is moved to the commit; files neither in it nor ignored are removed (nested repositories too); a merge, cherry-pick or revert in progress is abandoned. HEAD moves last. A ref that is empty or starts with - is refused.
snapshots()The snapshot refs, oldest first by committer date (to the second; ties by name). Shared by every worktree of the repository.

The whole worktree is affected, whatever subdirectory the workdir is. Ignored files and a .hmz/ (or a .humanize/ not yet moved) at the worktree's top are neither recorded nor removed. Snapshots persist until git update-ref -d refs/hmz/snapshots/<name>.

RewindError is raised for a workdir outside a git worktree, a ref git does not know, a name git refuses as a ref, a snapshot taken before the first commit rewound on a detached HEAD, or any other git failure (could not <snapshot|rewind|list the snapshots of> <workdir>: <what git said>).

A flow declaring GitEnvMixin or GitWorktreeEnvMixin for an environment whose machine has no git on its PATH, or BashEnvMixin for one with no bash, is refused before anything runs:

hmz exec: error: tries: 'repo' needs GitEnvMixin, which docker@gpubox/srv/repo does not support: GitEnvMixin needs git on the machine's PATH, and it has none

Budgets ​

Budget ​

python
class Graceful(pydantic.BaseModel): # frozen=True, extra="forbid"
    duration: bool = True
    cost: bool = True
    output_tokens: bool = True

class Budget(pydantic.BaseModel):   # frozen=True, extra="forbid", ser_json_inf_nan="strings"
    duration: timedelta | None = None     # >= 0
    cost: float | None = None             # >= 0, USD
    output_tokens: int | None = None      # >= 0
    graceful: bool | Graceful = True

    @property
    def graces(self) -> Graceful: ...     # `graceful`, one flag per limit
FieldLimits
durationWall-clock time from the start of the call (or turn) the budget belongs to: a deadline.
costUSD spent by turns under it.
output_tokensTokens written by agents under it.
gracefulTrue: a turn under way when a limit is reached may finish. False: it is cut off at once. A Graceful says it for each limit on its own: Budget(cost=5, output_tokens=200_000, graceful=Graceful(output_tokens=False)) cuts a turn off at its 200 000th token, but lets one that reaches $5 finish. A Graceful whose three flags agree is read as that one bool.

At least one of duration, cost, output_tokens must be set (a budget sets at least one of duration, cost, output_tokens), and none may be negative; otherwise pydantic.ValidationError. Budget(cost=math.inf) means unlimited and serializes as {"cost": "Infinity"}.

Which runs need one. Every run except of chat: from -p budget.<limit>= (see budget.*), /flow, or budget= in Python. Without it: <flow> requires a budget: specify with -p budget.cost=...,budget.duration=...,budget.output_tokens=.... chat given none runs under Budget(cost=math.inf).

Effective budget ​

Every call and every turn may carry its own budget (budget= of Flow.__call__ and Agent.run). They nest:

  • Cost and output tokens spent anywhere under a call count against it and every call above.
  • duration is a deadline per call: a call's deadline is the earliest of its own (start + duration) and every one above. Ten concurrent calls with an hour each spend one hour of their caller's.
  • ctx.budget reports, for each limit, the least over the chain of limit − spent under that call + spent under this call (the total this call may reach), duration as the time from this call's start to its deadline, and each limit as not graceful if any budget in the chain that sets it is not graceful for it; graceful is a bool where the three agree and a Graceful where they do not. cost is None where no budget limits it but another limit exists.
  • A turn is held to the least remaining of each limit over every budget above it plus its own, each limit graceful or hard as the budget leaving it says -- a hard token limit leaves a graceful cost limit graceful. Where two budgets leave the same amount, the one hard for that limit wins. A turn's own budget lasts one turn: a graceful one only counts; use graceful=False to cut a turn short.

Enforcement.

SituationResult
Turn requested with a spent limitThe BudgetExceeded leaf, before anything is sent: <ref>: its budget's duration is spent / cost is spent / output tokens are spent.
Limit reached mid-turn, gracefulThe turn completes and returns; the next turn raises.
Limit reached mid-turn, hardThe CLI is stopped; run raises the leaf.
A call's deadline passesThe call's task is cancelled after in-flight turns under it finish (graceful duration) or at once (hard); the cancellation surfaces from the call as DurationExceeded. A cancellation from anywhere else remains a CancelledError.
After any of the aboveThe budget stays spent: every later turn under it raises again.

Spending is read as the CLI reports it (polled every 1 s while the CLI is silent) and priced with Humanize's price table, which a run with a finite cost limit brings up to date before its first turn when it is missing or older than 24 h, however the run was started. A spend that is not a finite number, which a price list malformed mid-run leaves, raises CostExceeded on the next turn, whatever the budget. A model with no price costs 0, so a cost limit alone does not stop it: Run.unreadable() says so before the run, and hmz exec and the interface print it.

Usage ​

python
class Usage(pydantic.BaseModel):    # frozen=True, extra="forbid"
    duration: timedelta = timedelta(0)
    cost: float = 0.0
    output_tokens: int = 0

duration is the sum of the durations of every turn counted (concurrent turns each count). ctx.usage, Session.usage and Run.usage are Usage values. The run's total is written to the epic as the usage event.

Concurrency ​

A flow is a coroutine: asyncio.gather, asyncio.TaskGroup, asyncio.timeout and except* behave as anywhere.

  • Turns of one session are sequential; a second run while one is under way raises SessionError.
  • Cancelling a task in a turn interrupts that CLI.
  • A called flow's exception reaches except* with its own class.
  • Concurrent calls form a tree of contexts, each with its own usage.

Resumable flows ​

A flow with resumable=True keeps a journal while it runs. The newest run of it in a workspace is picked up by hmz exec --resume, /resume, Hmz().run(resume=True), or resume run on /epics (a chosen epic). "Of it" is by its canonical ref, <directory>:<flow>, or by the name it was run under: flows kept in directories of one name -- alice/kernel and bob/kernel -- share a ref, so the newest run of either is the one picked up.

RefusalMessage
flow not resumable<flow> does not support resuming, so there is no run to resume
no journal holding a flow call in this workspace<flow> has no run to resume here: none saved any progress
a given epic without one<epic> has no saved progress to resume from

A picked-up run is a new run with its own epic, whose began event names the epic it was picked_up from; the journal is copied into the new epic, compacted, and appended to. A session kept in state stays in the epic that kept it, and is read from there by every run picking it up.

Journal ​

resume.jsonl in the epic: JSON Lines, first line {"t":"journal","v":1}.

tFieldsWritten
callid (int), parent (int, 0 for the top call), digest (hex), seq (int), ref (canonical ref)when a call starts; batched
setid, key, valueon each state write; flushed immediately. A session in value is {"\u0000session": {"harness", "id", "at", "role"}}, at being its copy
delid, keyon each state delete; flushed immediately
sessionid, role, harness, model, session (the CLI's id)once the CLI names the session (at open or during the first turn); batched
tmpid, env (role), kind (temp_clone | scratch), name (the id), chain, workdirwhen a copy or scratch directory is made; batched
endid, ok (bool)when the call ends; batched

Batched records are written within 0.1 s or with the next state write. The file is synced on close. A line that does not parse is skipped when read. On resume the journal is rewritten once, compacted: each call, its final state, its sessions and temporary directories, its end.

Matching calls ​

A call's digest is blake2b(digest_size=16) over, \0-separated: the callee's canonical ref, the task, one role=spec per agent role then per environment role (each group in declaration order), and the params serialized as JSON.

RoleSpec in the digest
agent<harness>@<provider>/<model>:<effort>|<local>,<user>,<system>,<online>|<skill,skill…>
outworlderoutworlder, or outworlder:new for one from Outworlder.new()
environmentits derivation chain: <backend>@<provider><workdir> followed by #subdir(<path>), #worktree(<ref>,<dir>), #temp_clone(<id>), #scratch(<id>) per derivation; never the path a copy landed at

seq counts earlier calls under the same parent with the same digest.

CallOn resume
the top callPicks up the earlier top call unconditionally: ctx.resumed is True, state restored.
a call under a call that picked upPicks up the earlier call with the same parent, digest and the lowest unclaimed seq; otherwise starts afresh with an empty state.
a call of a non-resumable flowHas no state; still passes resumption to the calls it makes.

Temporary copies and scratch directories recorded in the journal are kept, so the resumed run finds them at the same deterministic paths.

Calling another flow ​

python
plan = load("humanize1:gen-plan")
await plan(f"plan this first: {task}",
           agents={"planner": agents["builder"], "analyst": agents["reviewer"]},
           envs={}, params=plan.expected_params())

load ​

python
def load(ref: str) -> Flow

Returns the flow ref names. Inside a run, the same ref from the same module is resolved once per run. A git+ ref returns a flow that is fetched when first used.

RaisesWhen
FlowRefError (a ValueError)not a ref; :<name> with no flow asking (':review' is relative to the flow asking, and no flow is asking)
FlowNotFoundthe ref names no flow; a git+ repository cannot be fetched or has no flow where the ref says
FlowLoadConflictimporting the module would replace a module another flow of a running run uses
FlowDefinitionErrorthe named module or flow is written wrong, or its import raised (importing the flow at <dir> failed: <error>)

Refs ​

FormNamesResolved
:<name>a flow in the same module as the code calling loadonly with a flow asking
<flow>the flow a bare name means in module <flow>in the asking flow's own directory of flows (for an installed flow, the flows installed from its flowverse), else nearest first
<flow>:<name>flow <name> of module <flow>as above
<user>/<flow>[:<name>]a flow official lists under flows/<user>/for an installed flow, the flows installed from its flowverse; else official only (no stand-in)
@<flowverse>/[<user>/]<flow>[:<name>]a flow of that flowverse: installed from it, or for local and user (no <user>/) in that directorythat flowverse only (no stand-in)
a path: ./…, ../…, /…, ~…a flow directory, its __init__.py, or a .py file (.py may be omitted)~ expanded
git+<url>[@<rev>][#<subdir>][:<name>]the flow in <subdir> of a repository, or at its root without #fetched; see below

Grammar rules:

  • A ref is a path only where it starts with ., / or ~. Anything else but a git+ ref is a name, of at most <user>/<flow> after an optional @<flowverse>/; one with a part too many, empty or hidden is a FlowRefError ('a/b/c' is not a flow's name, [@<flowverse>/][<user>/]<flow>; a path starts with ./, / or ~).
  • <name>, and each /-separated part of <subdir>, match [A-Za-z0-9_][A-Za-z0-9_.-]*.
  • A : is a sub-flow separator only where what follows contains no /; otherwise the whole string is the flow. In a git+ ref without #, a final :<name> is a sub-flow where it follows the repository's path (git+https://host/o/r@v1:review), and a port where a path follows it.
  • git+ URLs: scheme https, http, ssh, file or git; a host is required except for file; @<rev> is the last @ in the path; query and fragment of the URL are dropped. Errors: '<ref>': a ref naming a repository is git+<url>[@<rev>][#<subdir>][:<sub>], '<ref>': '<url>' is not a URL git can fetch, '<ref>': '<url>' names no repository, '<ref>': #<fragment> is not <subdir>[:<subflow>].
  • A bare module name picks the flow named after the directory (or file stem); else the only non-hidden flow; else FlowNotFound (<ref>: <dir> holds a, b and none is called '<stem>'; name one as <stem>:<flow>, or <ref>: <dir> defines no flow). A named flow that is absent: <ref>: <dir> holds no flow called '<name>'; it holds a, b.

git+ fetching. <rev> absent means the default branch. A 40-hex <rev> is used as a commit; anything else is resolved with git ls-remote. The checkout is kept at $TMPDIR/humanize-<uid>/pinned/<blake2b-8(url)>/<sha> and reused by every later fetch of that commit; each run fetches a given URL and revision at most once, on a worker thread, when the flow is first called (hmz exec -f git+… fetches before the run starts). Two processes fetching one commit at once may each clone it; whichever renames its clone into place second uses the checkout already there. Each git command has 120 s. The flow is <subdir>/__init__.py of the checkout (the root's without #). Where there is none: <ref>: <url> has no flow in <subdir>, which holds no __init__.py, ending ; there is one at #flows/<subdir> where a ref written for a repository that kept its flows under flows/ would find one there. The same checkouts are what installing copies from.

Module import rules.

  • A flow directory is imported as a module named after the directory where that top-level name is free (or already this directory's); otherwise as _hmz_flow_<stem>_<blake2b-4(path)>. The directory is put on sys.path, so modules and packages beside __init__.py import by plain name; those names are claimed by the flow.
  • A run imports a module at most once. Nothing a running run uses is removed from sys.modules. A module whose .py files changed (mtime or size) is re-imported by the next run that is not sharing it with a running run.
  • Two directories claiming one top-level name while a run uses one raise FlowLoadConflict (<a> and <b> both import '<name>', and a run going now uses the second); a name held in sys.modules by something that is not a flow raises importing <dir> would replace the module '<name>' (<file>). Flows kept in directories of one name -- alice/kernel and bob/kernel, or @a/loop and @b/loop -- claim the same name, so one run cannot call both.
  • A module's flows are the @flow objects defined in files inside its directory; one imported from elsewhere is not one of its flows.

Calling a Flow ​

python
async def __call__(self, task: str, *, agents: Mapping[str, Agent],
                   envs: Mapping[str, Env], params: FlowParams,
                   budget: Budget | None = None) -> Any
ParameterMeaning
taskThe callee's task.
agentsOne agent per callee role, keyed by the callee's role names.
envsOne environment per callee role.
paramsAn instance of the callee's model is used as is; another model or a mapping is validated (rules).
budgetA budget of the call's own, combined with the caller's remaining budget. Must be exactly a Budget (TypeError otherwise).

Returns what the callee returns; what it raises reaches the caller unwrapped.

ConditionRaises
called outside any runFlowRuntimeError: <ref>: a flow is called from inside a run; start one with run_flow
the caller (or any call above, or the run) has endedFlowCancelled: <ref>: <caller ref | the run> has ended
depth would exceed 64FlowDepthExceeded (a RecursionError): <ref>: flows are called 65 deep, and 64 is the most

What the called flow is handed ​

All checks run before the callee starts; a refusal means nothing of the callee ran and nothing was spent. Refusals for one kind of agent grant are computed once per role.

Given for a roleRequirementRefusal
nothing, required agent role—MissingRole: <ref>: no agent was given for '<role>'
nothing, required environment role—MissingRole: <ref>: no environment was given for '<role>'
nothing, Outworlder rolefilled with the run's outworlder—
nothing, LocalEnv rolefilled with the run's workspace, granted the callee's declarationCapabilityMissing if the workspace lacks a declared mixin
an agentcarries every mixin the role declaresCapabilityMissing: <ref>: '<role>' needs <mixins>, which the agent given was not granted
an agentits permission covers the role's _permissionPermissionTooNarrow: <ref>: '<role>' needs <p>, and the agent given holds <q>
an agent, harness-protocol rolethe same harnessHarnessMismatch: <ref>: '<role>' is <harness>, and the agent given is <other>
an agent, Outworlder role—CapabilityMissing: <ref>: '<role>' is an Outworlder, and <agent> is an agent
an environmentcarries every mixin the role declaresCapabilityMissing: <ref>: '<role>' needs <mixins>, which the environment given was not granted
an environmentmeets the role's resourcesResourceUnmet
an environment, LocalEnv roleon this machineCapabilityMissing (see LocalEnv)
anything not handed out by the run—RequirementError: <ref>: '<role>' was given <value>, which is not an agent the run handed out (or environment)

The callee receives exactly its declaration: each agent re-granted the callee role's mixins, _permission, _skills, _tools and _clis; each environment the callee role's mixins. Its hooks and sessions are its own; a session the caller opened cannot be used by the callee.

Hmz().flows.running() lists every flow call currently running in this process (oldest first), each with its ref, name, depth, start time, journal id, parent, task and whether it is resumable (SDK). A call adds no filesystem work, no task, and two stack frames.

Several flows in one module ​

A module may define several @flows with distinct names; each is <module>:<name>.

python
@flow(agents=Drafting, envs=Where, params=Idea, name="gen-idea")
async def gen_idea(task, *, agents, envs, params, ctx): ...

@flow(agents=Planning, envs=Where, params=Plan, name="gen-plan")
async def gen_plan(task, *, agents, envs, params, ctx): ...
  • The bare module name means the flow named after the module; else the single non-hidden flow; else it is refused:

    console
    $ hmz exec -f humanize1 -p budget.cost=5 "…"
    hmz exec: error: humanize1: ~/.hmz/flowverses/official/installed/humanize1 holds gen-idea, gen-plan, rlcr and none is called 'humanize1'; name one as humanize1:<flow>
  • Lists show the flow a bare name means under the module name, and every other visible flow as <module>:<name>. hidden=True flows are not listed and still load by <module>:<name>.

  • A module that fails to import is still listed, under its module name, with no description.

  • The module docstring's first line is used as the description of the flow the bare name means when that flow has none.

Where flows live ​

A flow is either a directory with __init__.py or a single .py file. Where both exist under one name, the directory wins. A single-file flow brings no skills.

my_loop/
├── __init__.py          the entry point
├── _prompts.py          imported by the entry point as `import _prompts`
└── skills/
    └── review-notes/
        └── SKILL.md

Names starting with _, and directories without __init__.py, are not flows.

Resolution of a bare -f <name> (and a bare load with no flow asking), first match wins:

OrderPlaceDirectory
1local.hmz/flows/ under the current directory
2user~/.hmz/flows/ (literally ~, not HUMANIZE_HOME)
3officialthe package's hmz/flows/builtin/, then ~/.hmz/flowverses/official/installed/ (under HUMANIZE_HOME where it is set)
4other flowverses~/.hmz/flowverses/<name>/installed/ (likewise), alphabetically

Only what is built in, installed or in local and user is found: never a flow an index only lists. <user>/<flow> looks in official only, and @<flowverse>/… in that flowverse only. A path is taken outright, as <path>/__init__.py, <path>, <path>.py (~ expanded). A name nothing answers to raises FlowNotFound (<ref>: no flow is called '<name>'; a path, <ref>: there is no flow at <path>), unless:

WhereMessage
an index lists it (<user>/ or @<flowverse>/ named: that one; bare: any, in listed order)<name>: not installed -- install it from /flow (flowverse <flowverse>)
it is said as names were before the @local/x: a flow of local is called @local/x now (official/x: x)
it is a relative path without its ./flows/x: a path starts with ./, / or ~, as ./flows/x does
otherwise, an index it could be in has not been fetched<name>: the official flowverse has not been fetched yet -- fetch it from /flow (the a and b flowverses have … for several)

Listed names.

Listed asIs
chat, flame_chasoidbuilt in, or installed from official's flows/<flow>/
alice/kernelinstalled from official's flows/alice/kernel/
@theirs/reviewinstalled from flowverse theirs
@theirs/alice/kernelinstalled from flowverse theirs's flows/alice/kernel/
@local/chatthis project's .hmz/flows/chat
@user/chat~/.hmz/flows/chat

-f takes the listed name; the TUI starts a flow by it ($@local/twice). What /flow remembers is keyed by the listed name (Settings), so what it remembered under a name from before the @ (local/x, <flowverse>/x, official/x) is not renamed and no longer matches any flow.

Copying here. Copy here on /flow's Installed page, and Hmz().flows.fork(name), copy the whole flow into .hmz/flows/<name> (or into=), without an installed flow's .installed.json. A name already present in either shape is refused (ValueError); a failed copy leaves nothing.

Skills ​

A flow directory's skills/ holds one directory per skill, each with a SKILL.md. A role names the skills its sessions carry in _skills.

python
class Reviewer(Agent):
    _skills = ("review-notes", "https://github.com/you/review-skills#writing-tests")
EntryIs
a nameskills/<name> in the flow's own directory
a string containing # or ://a git URL; #<skill> selects one skill from the repository's skills/*, without it every skill there
  • Skills are resolved once per run per role, when the flow is first called, before its first turn. Missing: FlowDefinitionError (<ref>: '<role>' names the skill '<name>', and <dir>/skills has none of it, or for a single-file flow ... and a flow that is one file has none of it). Unfetchable, or no such skill in the repository: <ref>: '<role>' names a skill that cannot be fetched: <reason>.
  • Installing a flow fetches every URL its roles' _skills write as string literals into the installed flow's own skills/<name>/, recorded in its .installed.json; a run of it reads them from there and fetches nothing. One that cannot be fetched fails the install. A URL the source does not spell out is fetched at run time, as below.
  • A flow that was never installed -- yours, a builtin, a git+ ref -- clones the repository into $TMPDIR/humanize-<uid>/skills/<owner>-<repo>-<sha256(url)[:12]> and fetches it again (fetch --depth 1 + reset --hard) the next time a run needs it; a failed re-fetch uses the existing copy.
  • The flow's own skill wins a name also held by a repository; a #<skill> URL whose skill the flow has of its own gives the role the flow's.
  • Skills are lodged at the harness's user level, in the virtual home each of its processes is started in, or named in its configuration (opencode, mimo); never in the workspace. Nothing is installed. A harness with neither carries none.
  • derive(skills=…) narrows them for part of a flow.

Flowverses ​

A flowverse is an index: a git repository of manifests, one per release of a flow. Nothing else in it is read, and nothing in it is imported. What runs is what was installed from it.

ManifestListsIndex name
flows/<flow>/<version>/flow.yamla flow listed bare: in official, humanfia's own<flow>
flows/<user>/<flow>/<version>/flow.yamla flow listed under <user>, the GitHub owner of its repository in lower case ([a-z0-9][a-z0-9-]*)<user>/<flow>

A directory of flows/ holding a SemVer directory is a flow; one holding none is a <user>. One that is not a GitHub user name is skipped (Alice is neither a flow's releases nor a GitHub user's flows), and so is humanfia (humanfia's flows are listed bare, as flows/<flow>/<version>/).

FlowverseLocationFetchedRemovable
officialpackage hmz/flows/builtin/ + what is installed from the clone of https://github.com/humanfia/flowverse at ~/.hmz/flowverses/official/index/in the background each time hmz starts (cloned the first time); on demandno
local.hmz/flows (relative to the current directory)neverno
user~/.hmz/flowsneverno
any otherwhat is installed from the clone at ~/.hmz/flowverses/<name>/index/clone on add; in the background each time hmz starts; on demandyes

Order. Listed: official, others alphabetically, local, user. Looked up: local, user, then the listed order.

OperationBehaviourErrors
add <url> [<name>]git clone --depth 1 into .index.XXXXXXXX beside the target, then renamed into place. <url> may be owner/repo (GitHub) unless a local path of that name exists. Name defaults to the repository name less .git. Installs nothing.ValueError: name not [A-Za-z0-9][A-Za-z0-9._-]*; official/local/user; already exists. OSError: git missing, clone failed (60 s timeout)
fetch <name>Clone if never fetched; otherwise git fetch --depth 1 origin HEAD + git reset --hard FETCH_HEAD (local edits to tracked files are lost). Installed flows are not touched.ValueError: unknown; local/user; a directory that is not a clone
remove <name>Deletes ~/.hmz/flowverses/<name>/ in one rename: the clone and every flow installed from it.ValueError for the three fixed ones

A stale half-clone .index.* older than 60 s is removed before the next clone of that flowverse. The background fetch at start skips a clone whose tracked files have local edits. Where a flowverse came from is shown with any user:password@ in its URL replaced by ***@. Managed on /flow's Flowverses page and with Hmz().verses.

Manifests ​

yaml
name: recursive_lean_prover
version: 0.1.0
description: Recursively plan, prove, compare, review and catalogue Lean theorems.
repo: humanfia/recursive-lean-prover-flow
ref: v0.1.0
commit: <40 hex>
subdir: recursive_lean_prover
license: Apache-2.0
dependencies:
  humanize1: ">=0.1.0,<0.2.0"
KeyTypeRequiredRule
namestryes[a-z][a-z0-9_]*; equal to the <flow> directory; listed bare, not a built-in flow's name
versionstryesSemVer 2.0.0; equal to the <version> directory
descriptionstrnoone line
repostryesowner/repo (GitHub, fetched from https://github.com/<owner>/<repo>) or a URL starting https://, http://, ssh://, git:// or file://. On GitHub, under flows/<user>/: <user>'s; in official, listed bare: humanfia's
refstrnothe tag or branch the release was cut from; informational
commitstryes40 hex digits (case-folded); what is installed
subdirstrnothe directory holding the flow (__init__.py, or <name>.py); "" or . for the root; no part hidden or climbing
licensestrnoSPDX identifier
dependencies{flow: range}noflows of the same flowverse, by index name (humanize1, alice/kernel); a range is clauses joined by ,, all of which hold, each <, <=, >, >=, == or != and a version, a bare version meaning ==

Other keys are ignored. A manifest that is unreadable, not a mapping, fails a rule above, or is missing from a version directory is skipped: listed with why (name: …, version 0.2.0 is not the directory it is in, 0.1.0, rlar is built into Humanize; nothing may be installed over it, bob/kernel-flow is bob's, so it is listed under flows/bob/, bob/loop-flow is bob's; only humanfia's are listed bare here) and never an error. Hidden directories are not read.

Within a flow, releases are ordered by SemVer, newest first. The release a flow is shown and installed by when none is named is the newest that is not a prerelease, else the newest prerelease.

Installing ​

Installing <flow> (by index name) at <version> (default as above) from flowverse <verse>:

  1. Plan. The release, then for each dependency in name order: skipped where the release already chosen for it, or the one installed, satisfies the range; else the newest in range (as above), visited in turn. Refused with ValueError:

    RefusalMessage
    no such flowverse, not an index, not fetchedno flowverse called '<verse>', <verse> is a directory of your own flows, not an index, <verse> has not been fetched yet; fetch it first
    no such flow or release<verse> lists no flow called <flow>, <verse> lists no release <version> of <flow>
    a cycle<a> needs itself, through <a> -> <b> -> <a>
    nothing in range<flow> <version> needs <dep> <range>, and <verse> lists no release of it in that range
    two ranges for one flow that one release cannot meet<flow> <version> needs <dep> <range>, and <dep> <v> is what the rest of this install needs
    another installed flow's range broken<other> <v> needs <flow> <range>; installing <flow> <version> would break it
    a flow and a user of one name, one inside the otheralice would be installed where alice/kernel is, out of <verse>; uninstall alice/kernel first
  2. Copy, dependencies first, the asked-for flow last; a flow already installed at the same version and commit is left. Each is fetched as a git+ ref at commit (the same cached checkout), and subdir is copied, without .git and __pycache__, into .<flow>.XXXXXXXX beside the target; a single <flow>.py becomes that directory's __init__.py. .installed.json is written into it, and it is renamed into ~/.hmz/flowverses/<verse>/installed/[<user>/]<flow>/, the release it replaces moved aside first. No subdir holding the flow: ValueError (<repo> at <commit[:12]> has no flow in <subdir|its root>: neither __init__.py nor <flow>.py); fetch or copy failed: OSError.

.installed.json: verse, name, version, commit, repo, ref, subdir, dependencies, owner ("" for a flow listed bare). A directory without one that reads is not an installed flow.

Uninstalling renames the directory aside and deletes it, and the <user>/ directory with it where it was that user's last flow. Refused with ValueError where another flow installed from the same flowverse lists it in dependencies: <a>, <b> need <flow>; uninstall that first.

Updates. An installed flow has an update where its index, as last fetched, lists a newer release: a prerelease only for a flow installed at one. After the background fetch at start, the TUI says once, dim: updates available: <flow> <v> ↑ <newer>[, …]; update from /flow.

From inside a flow installed from a flowverse, <flow> and <user>/<flow> (humanize1:rlcr, alice/kernel) find the flows installed from that flowverse first, whichever directory of them the flow asking is in.

An installed flow is code

Adding or fetching a flowverse imports nothing. Listing flows imports the entry point of every installed flow, and a run executes it. Installing one trusts its repository with this machine.

The flows in the package ​

hmz/flows/builtin/, there with nothing fetched. Every flow here has a workspace: LocalEnv role.

FlowAgent rolesRoles needResumableBudgetGrant
chatassistant, human: OutworlderBudget(cost=math.inf) unless one is givenevery mixin the harness serves
ralph_loopagentrequiredwhat each role declares
stateful_ralphagent✓requiredwhat each role declares
continue_loopagentrequiredwhat each role declares
goalworkerGoalCommandAgentMixinrequiredwhat each role declares
flame_chasefirst_chaser, second_chaser✓requiredwhat each role declares
rlaractor, reviewer✓requiredwhat each role declares

chat opens one session and takes one turn per line the outworlder says; under hmz exec (outworlder away) it takes the task as its one turn and returns. Interfaces show that outworlder as the conversation itself: its wait for the next line is no separate question, a follow-up or steer typed in the assistant's conversation is that line, and the agent's own questions show there with their options. The first turn's failure ends the run; later failures are reported and the conversation continues.

The loops are each the plainest form of their idea, written to be read and copied. Each runs until its budget is spent (rlar also until its reviewer says done), and a turn that fails ends the run with that failure; none prints, pauses or counts rounds. What a resumable one keeps in its state is what its next turn needs: stateful_ralph its session, flame_chase whose turn is next (turn), rlar the last review (notes, removed when done).

The official flowverse ​

The flows Humanize offers besides the ones in the package, listed by humanfia/flowverse, each released from a repository of its own (humanfia/<name>-flow), and run once installed. Every flow here also has a workspace: LocalEnv role; human is an Outworlder. None declares a budget of its own. Read Security before running any.

FlowAgent rolesRoles needResumable
humanize1:gen-ideadrafter
humanize1:gen-planplanner, analyst
humanize1:rlcrbuilder, reviewer, humanbuilder: PermissionRequestHookAgentMixin✓
flame_chasoid:parallelcoordinator, lane_1_actor_a … lane_3_actor_b, human✓
flame_chasoid:parallel_git_prorchestrator, lane_1_actor_a … lane_3_actor_b, human✓
flame_chasoid:agent_cleanup_ralph_loopagent, cleaner, humanSteeringAgentMixin on both agents✓
flame_chasoid:agent_cleanup_flame_chasefirst_chaser, second_chaser, cleaner, humanSteeringAgentMixin on all three agents✓
recursive_lean_proverworker, reviewerworker: PermissionRequestHookAgentMixin✓
aotwriter, critic, human

Command-line specs ​

sh
hmz exec -f <flow> [-a <role>=<spec>[,…]]… [-e <role>=<spec>[,…]]… [-p <key>=<value>[,…]]…
         [--profile-flow] [--profile-environment] [--resume] [--json] <task>

-a, -e and -p may each be repeated; every occurrence is a comma list. A comma separates items only where it is followed (after optional spaces) by <key>= or budget.<key>=, with <key> matching [A-Za-z_][\w-]*; so -p note=one,two is one item. An empty item is refused (<flag> '<value>': an item is empty). The same syntax is used by /flow, the settings file, and Hmz().run(agents={role: spec}, envs={role: spec}). Every refusal below exits hmz exec with status 2 before anything runs. Other flags: CLI.

-a: agents ​

<role>=<cli>[@<provider>]/<model>[:<effort>]
PartRule
<role>A Python identifier; a role the flow declares that is not an Outworlder.
<cli>A HarnessKind value other than acp, or the name a CLI was added under on the Accounts page (then the harness is acp).
<provider>An account of that CLI (see Providers). Absent: the account the CLI is already signed into.
<model>Everything after the first /, less a trailing :<effort>; may contain / and :. Non-empty.
<effort>After the last :, where what follows it is spelled as an effort: words of letters joined by -, _ or a space (high, extra-high, as configured), or nothing. Otherwise -- custom_provider:gateway/m, qwen3:8b -- the : is the model's, and so is everything after it. Left off, or auto, or empty: no rung, stored as "". A model whose own name ends in :<word> is written with its effort after it: qwen3:latest:auto. Valid words per CLI: Agents.
InputMessage
no =, or empty role-a '<item>': expected <role>=<harness>[@<provider>]/<model>[:<effort>]
role not an identifier-a '<item>': '<role>' is not a place a flow could declare: what is written before `=` is a field of the tuple of agents the flow declares, so it is a Python identifier
unknown CLI, empty model, or no /-a '<item>': expected [NAME=]CLI[@PROVIDER]/MODEL[:EFFORT]
@ with nothing after-a '<item>': expected an account after @, as in claude@deepseek/MODEL:EFFORT
role twice-a: the role '<role>' is given twice
role not declared<flow> has no agent role '<role>'; available roles are '<a>', '<b>'
role is an Outworlder<flow>: '<role>' is assigned automatically by the runtime and cannot be set with -a
harness lacks a declared mixin<flow>: '<role>' needs <Mixin>, which <harness> does not support
role typed as another harness<flow>: '<role>' requires <harness>, but got <other>
required role missing<flow> needs an agent for '<role>'; specify each with -a ROLE=CLI/MODEL:EFFORT
effort, model or account the CLI cannot be configured atHarnessUnrecoverable: <spec>: <reason>
an added CLI not added on this machineHarnessNotInstalled: <cli>: no such CLI has been added on this machine

-e: environments ​

<role>=<backend>[@<provider>][/<workdir>]

An @ is written only before a provider: the name of a runtime saved for that backend, or, for ssh alone, a host nobody saved, in brackets. None is this machine.

FormIs
local/abs/patha directory on this machine; local takes no provider
ssh@<name>/abs/patha directory on the host the ssh runtime saved as <name> reaches
ssh@[<host>]/abs/patha directory on a host nobody saved: [user@]host[:port] or an alias of the ssh config, handed to ssh as it is; no fallback
ssh@<name>/~/path, ssh@[<host>]/~/pathunder the login's home there (the leading / before ~ is dropped)
docker/abs/path, docker@<name>/abs/patha container of its own on docker's default daemon here, or on a saved docker runtime's daemon; the path is on the daemon's host and is mounted at the same path
swarm/abs/path, swarm@<name>/abs/patha container of its own as the one task of a service on the swarm this machine manages, or on a saved swarm runtime's swarm, on whichever node has room; the path is on that node and is mounted at the same path
apple-container/abs/path, apple-container@<name>/abs/pathan Apple container of its own on this Mac, with nothing saved or as a saved runtime of that backend shares it out; the path is this Mac's and is mounted at the same path
slurm/abs/path, slurm@<name>/abs/patha job of its own on the Slurm cluster this machine submits to, with its defaults or as a saved Slurm runtime asks for one, on whichever node the cluster gives it; the path is on a shared filesystem, the same here and on that node; ~/… is this user's home
ssh@<name>, docker@<name>, swarm@<name>, apple-container@<name>, slurm@<name>the workdir the saved runtime was saved with
InputMessage
not <role>=…, or a stray bracket-e '<item>': expected <role>=<backend>[@<provider>][/<workdir>]
no /workdir and no saved runtime workdir-e '<item>': expected <role>=<backend>[@<provider>][/<workdir>]; /<workdir> may be left off only for a runtime saved with one
role not an identifier-e '<item>': the role '<role>' is not an identifier
unknown backend-e '<item>': '<backend>' is not a backend; one of local, ssh, docker, swarm, apple-container, slurm
ssh without host-e '<item>': ssh needs a host: ssh@<saved host>/<workdir>, or ssh@[user@host:port]/<workdir> for a host not saved
ssh@<name>, nothing saved as <name>-e '<item>': no ssh host is saved as '<name>'; write <role>=ssh@[<name>]/<workdir> for a host not saved
ssh@[<host>], not a host-e '<item>': '<host>' is not an ssh host, as [user@]host[:port]
brackets on docker, swarm, apple-container or slurm-e '<item>': only ssh takes a host nobody saved; <backend>@<name> names a <backend> runtime saved on the runtimes page of /settings
docker@local, swarm@local, apple-container@local, slurm@local, nothing saved as local-e '<item>': <backend> on this machine names no provider; write <role>=<backend>/<workdir>
docker@<name>, swarm@<name>, apple-container@<name>, slurm@<name>, nothing saved as <name>-e '<item>': no <backend> runtime is saved as '<name>'; save one on the runtimes page of /settings, or write <role>=<backend>/<workdir> for <backend> on this machine
docker@, swarm@, apple-container@ or slurm@ with nothing after-e '<item>': an @ is written only before a provider; write <role>=<backend>/<workdir>
local with an @-e '<item>': local takes no provider; write <role>=local/<workdir>
role twice-e: the role '<role>' is given twice
role not declared<flow> has no environment role '<role>'; available roles are …
role is a LocalEnv<flow>: '<role>' is the workspace the run started in and cannot be set with -e
required role missing<flow> needs an environment for '<role>'; specify each with -e ROLE=BACKEND[@PROVIDER]/WORKDIR
machine unreachable, workdir missing, mixin unsupported, resources shortrefused when the run probes every environment, before the flow is called

Machines and providers: Machines.

-p: params ​

<key>=<value>; <key> matches [A-Za-z_][\w-]*; the value is the text after the first =, unparsed until validated. budget.<limit>=<value> is the run's budget rather than a param of the flow's.

InputMessage
not <key>=<value>-p '<item>': expected <key>=<value>
key twice-p: '<key>' is given twice
budget with no limit-p '<item>': a budget is given a limit at a time, as budget.cost=5
does not validatethe ParamsError text

budget.*: the run's budget ​

Limits budget.duration, budget.cost, budget.output_tokens, budget.graceful and budget.graceful.<limit>, each at most once across all -p, among the flow's params or apart from them: -p rounds=3,budget.cost=5 -p budget.duration=1h. No flow may declare a param budget.

LimitAcceptedExamples
budget.durationseconds (90, 1.5); units w d h m s, each at most once, decimals allowed (1h30m, 2d, 1.5h); ISO 8601 (PT1H30M); HH:MM:SS. Finite, ≥ 0.budget.duration=6h
budget.costUSD, optional leading $; inf for none; ≥ 0, not NaNbudget.cost=$20
budget.output_tokenswhole number, _ ignored, suffix k (×1000) or m (×1 000 000), decimals allowed if the result is wholebudget.output_tokens=1.5m
budget.graceful1 true yes on / 0 false no off (case-insensitive), for every limitbudget.graceful=false
budget.graceful.duration, budget.graceful.cost, budget.graceful.output_tokensas budget.graceful, for that limit alone, over budget.gracefulbudget.graceful.output_tokens=false
InputMessage
unknown limit-p budget.<limit>: not a limit; one of budget.duration, budget.cost, budget.output_tokens, budget.graceful, budget.graceful.duration, budget.graceful.cost, budget.graceful.output_tokens
limit twice-p: 'budget.<limit>' is given twice
bad duration-p budget.duration: '<v>' is not a duration: use seconds, 1h30m, or ISO 8601 like PT1H30M; '<v>' names a unit twice; '<v>' is negative; '<v>' is not a valid duration: must be finite and not negative; duration '<v>' is too long
bad cost-p budget.cost: '<v>' is not a valid USD cost
bad tokens-p budget.output_tokens: '<v>' is not a valid token count: expected a number like 200000 or 200k; '<v>' must be a whole number of tokens
bad graceful-p budget.graceful: '<v>' must be true or false (or budget.graceful.<limit>)
no limit set-p budget.*: Value error, a budget sets at least one of duration, cost, output_tokens
no budget.* for a flow that needs one<flow> requires a budget: specify with -p budget.cost=...,budget.duration=...,budget.output_tokens=...

Harness placement ​

Where each agent's harness (its CLI and the process supervising it) runs, relative to the machine its environment's work is on. Not said on the command line: a session has an env location, local where its work is here and standalone elsewhere, and a harness location, which for standalone work is the harness setting of the saved runtime the work is on, one value and nowhere else.

WorkHarness
here (env location local)here (harness location local)
elsewhere, harness env (the default; also a machine nobody saved)on the environment's machine, natively. Refused where the CLI is not on that machine's PATH, where a fenced session's fence cannot be held there, where the machine cannot be asked, or where an on_pre_tool_use/on_permission_request hook is hung when the session opens (an on_ask_user hook is not)
elsewhere, harness localhere, anchored to the machine, in a mirror of its work
elsewhere, harness ssh:<name>, docker:<name>, swarm:<name>, apple-container:<name> or slurm:<name> (harness location standalone)on that runtime, in a mirror there, acting on the work through the anchor. Refused where it cannot be opened or reached, has no share left, or the role's permission is anything but every scope ALL

The setting is the one of the runtime actually opened for the work; a runtime a harness is put on is opened as an environment of its own (its workdir, else ~ over ssh, else $TMPDIR/humanize-<uid>/harness for a daemon here), probed before the flow is called and closed with the run, and its own setting is never read.

A session's harness is placed as its first turn starts, for the machine that turn's env is on, and placed again where a later turn carries the session to another workdir.

Probing. It is settled once per agent role and machine. "The CLI is there" is asked by running /bin/sh -c 'command -v -- "$1" || command -v -- "$2" || exit 69' with the CLI's program and its basename down the same connection a turn on the environment's machine uses (300 s limit; stopped with the run). Whether the machine can hold a fence is asked once per kind of fence (network cut or not) in the anchor handshake.

ConditionRefusal
a harness runtime, role not granted everythingHarnessSandboxed: <role>=<spec>: <AgentClass>: a fence cannot hold a harness that runs on another machine
a harness runtime that cannot be opened, reached or given a containerthe runtime's EnvUnavailable, EnvConnectionError or ResourceUnmet
env: CLI missingHarnessNotInstalled: <cli> is not installed on <backend>[@<provider>]: <install hint> there, or set the harness of the runtime it is on to local
env: fence not holdableHarnessSandboxed: <backend>[@<provider>] cannot fence the agent to its permission: it needs Landlock; grant the agent everything, or set the harness of the runtime it is on to local
env: machine did not answerHarnessUnrecoverable: <backend>[@<provider>] did not say within 300s whether <cli> is there, or could not be asked whether <cli> is there: <reason>
env: gating hook hungUnsupportedOperation: a pre_tool_use[ and a …] hook cannot gate the tools of a CLI on another machine; set the harness of the runtime it is on to local

Each refusal is raised, of its type, as <backend>[@<provider>]: <cli>'s harness cannot run where its harness setting (<setting>) says: <refusal>; the harness is never put anywhere else. Every agent is settled against every environment (the workspace included) once the environments are probed, before the flow is called, so a run refuses as a line to correct rather than failing at a session; a session started later (a callee's, under a narrower permission) is still refused as its first turn starts. Where each session's harness went is recorded in the epic (opened.harness: local, env, <backend>:<name>; self from an earlier Humanize is read as env), for sessions whose work was on another machine.

Command-line refusals ​

The runner refuses, before any agent starts, with the messages in the tables above, and also:

ConditionMessage
duplicate role across forms<flow>: duplicate agent role '<role>' / duplicate environment role
a flow that will not loadthe FlowNotFound / FlowRefError / FlowDefinitionError / FlowLoadConflict text
--resume refusalssee Resumable flows

In the TUI, /flow asks the same questions and remembers the answers per workspace and flow (Settings). From Python, Hmz().run takes the same specs as mappings.

Stopping ​

A run ends when the top flow returns or raises. From outside:

MeansEffect
a budgetsee Enforcement
ctrl+conce under hmz exec; twice in the TUI
Run.stop()from any thread

A stop cancels the flow at its current await: the turn under way is interrupted, finally blocks run, TaskGroups cancel their children. On the way out every session is closed and every copy and scratch directory removed (kept, with the journal, for a resumable run). The epic records ended.how = stopped for a stop from outside or a spent budget.

Errors ​

Every exception the flow API raises derives from FlowException, in three branches:

FlowException
├── FlowRuntimeError                  Humanize refused something, or a budget ran out
│   ├── FlowNotFound · FlowRefError (ValueError) · FlowDefinitionError · FlowLoadConflict
│   ├── RequirementError              what was given does not meet the declaration; nothing ran
│   │   └── MissingRole · CapabilityMissing · PermissionTooNarrow · ResourceUnmet · HarnessMismatch
│   ├── CapabilityNotGranted          used what the role did not declare
│   ├── ParamsError (ValueError) · FlowDepthExceeded (RecursionError)
│   ├── BudgetExceeded
│   │   └── DurationExceeded (TimeoutError) · CostExceeded · OutputTokensExceeded
│   └── FlowCancelled · StateNotSerializable (TypeError) · OutworlderAway
├── HarnessError                      a coding agent CLI could not take the turn
│   └── HarnessNotInstalled · HarnessContended · HarnessThrottled · HarnessRefused
│       ModelUnavailable · HarnessMissing · HarnessSandboxed · HarnessKilled · HarnessDropped
│       HarnessUnrecoverable · OutputSchemaError (ValueError) · SessionError · UnsupportedOperation
└── EnvError                          an environment could not do what it was asked
    └── EnvUnavailable · EnvConnectionError (ConnectionError) · EnvCommandTimeout (TimeoutError)
        EnvFileNotFound (FileNotFoundError) · EnvPermissionDenied (PermissionError)
        WorktreeError · TempCloneBusy · ScratchError · RewindError
  • Where a builtin names the failure the leaf also derives from it: except TimeoutError catches EnvCommandTimeout and DurationExceeded.
  • Every class takes its message as its only argument, so each pickles and crosses a process boundary as itself. A called flow's exception is never wrapped.
  • An exception from the flow's own code (a KeyError) is not converted.
  • HarnessThrottled and HarnessDropped are transient; BudgetExceeded, HarnessRefused and ModelUnavailable recur on retry.
ClassBasesRaised when
FlowExceptionExceptionroot
FlowRuntimeErrorFlowExceptionHumanize refused something, or a limit ran out; also a flow called outside a run
FlowNotFoundFlowRuntimeErrora ref names no flow; a git+ repository cannot be fetched
FlowRefErrorFlowRuntimeError, ValueErrora ref is malformed, or relative with nothing to be relative to
FlowDefinitionErrorFlowRuntimeErrora flow is written wrong (tables); its module fails to import; a skill a role names is missing
FlowLoadConflictFlowRuntimeErrorimporting would replace a module a running run uses
RequirementErrorFlowRuntimeErrorwhat was given does not meet the declaration; nothing ran. Raised directly for a role the flow does not declare, a runtime-filled role given, or an object the run did not hand out
MissingRoleRequirementErrora required role was not given
CapabilityMissingRequirementErroran agent or environment lacks a declared mixin (including GitEnvMixin or GitWorktreeEnvMixin on a machine without git, BashEnvMixin on one without bash), a LocalEnv given another machine, an outworlder given for a role it cannot fill
PermissionTooNarrowRequirementErroran agent's permission does not cover its role's
ResourceUnmetRequirementErrora machine has fewer CPUs/GPUs or less memory than declared, or a docker runtime has not that much left
HarnessMismatchRequirementErrora harness-protocol role got another harness
CapabilityNotGrantedFlowRuntimeErroran operation the role did not declare; widening a grant with derive; on_outworlder_run on an outworlder not from new()
ParamsErrorFlowRuntimeError, ValueErrorparams do not validate
FlowDepthExceededFlowRuntimeError, RecursionErrora call would be 65 deep
BudgetExceededFlowRuntimeErrora budget of this call or one above is spent; sticky
DurationExceededBudgetExceeded, TimeoutErrorduration elapsed
CostExceededBudgetExceededcost spent
OutputTokensExceededBudgetExceededoutput_tokens spent
FlowCancelledFlowRuntimeErrorthe run, or a caller above, has ended while this call still runs
StateNotSerializableFlowRuntimeError, TypeErrora ctx.state key is not a str, or a value is not JSON-serializable
OutworlderAwayFlowRuntimeErroran away outworlder was asked for a schema with a field without default
HarnessErrorFlowExceptiona CLI could not take a turn or do what was asked
HarnessNotInstalledHarnessErrorthe CLI (or its SDK) is not installed where its harness runs; a command that exits 127
HarnessContendedHarnessErrortwo turns reached one local store of the CLI at once, and this one lost
HarnessThrottledHarnessErrorrate limit or spent quota
HarnessRefusedHarnessErrorthe provider refused the credential
ModelUnavailableHarnessErrorthe model is not served to this account, retired, or unknown
HarnessMissingHarnessErrorthe CLI would not start
HarnessSandboxedHarnessErrorthe fence cannot be held (by the CLI or the machine)
HarnessKilledHarnessErrorthe CLI died mid-turn (signal, OOM)
HarnessDroppedHarnessErrorthe connection to the CLI or provider broke mid-turn; any OSError from a turn
HarnessUnrecoverableHarnessErrorany other failure no retry would change; a CLI that cannot be configured as asked
OutputSchemaErrorHarnessError, ValueErrorthe answer is not the output_schema
SessionErrorHarnessErrora session is closed, another agent's, busy, not taking a turn to steer, interrupted, not forkable yet, or its prompt was blocked by a hook
UnsupportedOperationHarnessErrorthe harness cannot do this at all (fork; fork elsewhere; carry a kept session on where its CLI cannot read it; outworlder fork or steer); any NotImplementedError from a turn
EnvErrorFlowExceptionan environment could not do what it was asked
EnvUnavailableEnvErrorthe machine is gone or the workdir does not exist
EnvConnectionErrorEnvError, ConnectionErrorthe connection to a remote environment failed
EnvCommandTimeoutEnvError, TimeoutErrorexec ran past its timeout and was killed
EnvFileNotFoundEnvError, FileNotFoundErrorread of a missing file
EnvPermissionDeniedEnvError, PermissionErrora read, write or command refused for want of permission
WorktreeErrorEnvErrorderive_worktree failed
TempCloneBusyEnvErrorderive_temp_clone of an id another environment holds
ScratchErrorEnvErrora scratch directory could not be made or removed
RewindErrorEnvErrorsnapshot, rewind or snapshots failed

How a failed CLI turn maps to a leaf: the fault coganchor classifies is mapped contended → HarnessContended, throttled/spent → HarnessThrottled, refused → HarnessRefused, unlisted/retired → ModelUnavailable, missing → HarnessMissing (HarnessNotInstalled when the exit status is 127), sandboxed → HarnessSandboxed, killed → HarnessKilled, dropped → HarnessDropped, anything else (unmirrored and fenced among them) HarnessUnrecoverable. A turn stopped by the driver is SessionError.

Testing a flow ​

hmz.sdk.fakes (import as from hmz.sdk import fakes; the dotted hmz.sdk.fakes is not importable) runs a flow through the real engine over in-memory drivers: no CLI, no machine, no tokens, no waiting. Guide: Testing a flow.

python
from hmz.flows import Budget, CostExceeded
from hmz.sdk import fakes

async def test_held_to_budget():
    agent = fakes.FakeAgentDriver(cost=1.0)
    with pytest.raises(CostExceeded):
        await fakes.run_fake("my_loop", "go", agents={"agent": agent}, budget=Budget(cost=2.5))
    assert len(agent.prompts) == 3

run_fake ​

python
async def run_fake(
    flow: Flow | str, task: str = "", *,
    agents: Mapping[str, AgentDriver | OutworlderDriver | Reply] | None = None,
    envs: Mapping[str, EnvDriver | Mapping[str, bytes | str]] | None = None,
    params: FlowParams | Mapping[str, Any] | None = None,
    budget: Budget | None = None,
    outworlder: OutworlderDriver | None = None,
    local: EnvDriver | None = None,
    journal: Path | None = None,
    resume: bool = False,
    recorder: Recorder | None = None,
) -> Any
ParameterDefaultMeaning
flowA Flow, or a ref loaded relative to the caller as load does.
task""The task.
agentsNonePer role: a driver, or a Reply for a FakeAgentDriver. An omitted required role gets a fake of the harness its type names (Claude Code otherwise) replying "ok". A reply given for an Outworlder role makes the run's outworlder.
envsNonePer role: a driver, or the initial files of a FakeEnvDriver. An omitted role gets an empty one large enough for its declared resources.
paramsdefaultsParams or a mapping.
budgetunlimitedThe run's budget.
outworlderan away oneFills Outworlder roles.
localFakeEnvDriver(workdir="/here")Fills LocalEnv roles.
journalNonePath of a resumable flow's journal.
resumeFalsePick up journal.
recorderNoneinternal Receives entered(call), left(call, error), spawned(call, role, session, driver), optionally began, named, closed.

Returns the flow's return value. A NotRequired role nobody gave is omitted. Fake turns report the spending they are told to, so budgets, usage and sticky exhaustion behave as in a real run.

FakeAgentDriver ​

python
FakeAgentDriver(harness: HarnessKind | str = "claude", *, reply: Reply = None,
                model: str = "fake", effort: str = "", provider: str = "",
                capabilities: Iterable[type] | None = None, cost: float = 0.0,
                output_tokens: int = 1, seconds: float = 0.0,
                forks: bool | None = None, names_late: bool = False)
ParameterMeaning
harnessWhich harness; serves that harness's mixins unless capabilities is given.
replyOne answer for every turn; a list consumed one per turn; or a function (prompt, *, output_schema, session), sync or async. An answer is text, a pydantic model, or a mapping or JSON text validated into the schema asked for. None or an exhausted list answers "ok" or the schema's defaults.
model, effort, providerReported identity.
capabilitiesMixins served, overriding the harness's.
cost, output_tokens, secondsReported per answer; nothing waits.
forksWhether it can fork; None for whether its harness can (all but cursor-agent, mcode, agy, dsh). A fork is refused as Agent.fork refuses it: of a session with no turn, onto another machine, or into another workdir on a harness other than Claude Code, Codex or Kimi Code; and at its first turn if the parent has taken a turn since.
names_lateSessions have no id until their first turn gets past its hooks.
Attribute
promptsEvery prompt any session was given, including hook context and STOP reasons.
sessionsEvery FakeSession, in order.
live, peakSessions open now; the most open at once.
closedHow many times the driver was closed.

FakeSession ​

Passed to a reply function as session=. Each turn fires SESSION_START (first turn), USER_PROMPT_SUBMIT and STOP; closing fires SESSION_END; a blocking STOP continues the turn.

MethodFires
await tool(name, input=None) -> boolPRE_TOOL_USE, then PERMISSION_REQUEST where served. Returns whether the tool would run.
await ask(question, options=()) -> str | NoneASK_USER; returns the answer. UnsupportedOperation where AskUserHookAgentMixin is not served.
await notify(message)NOTIFICATION.
await subagent(name, task="", said="") -> strSUBAGENT_START, SUBAGENT_STOP where served.
await until_steered() -> strWaits for a steer and returns it.
await use(name, input=None) -> strCalls one of the role's fake tools, as the agent would, and returns what the agent would read. SessionError for a name it does not offer.
await run_cli(argv) -> tuple[int, str, str]Runs one of the role's fake command lines, argv starting with its name, as the agent's shell would: the status, stdout and stderr. SessionError for a name it does not shelve.

Attributes: prompts, requests, steered (prompt, queued), tools (name, input, ran), offered (the fake tools), shelved (the fake command lines), forked_from, permission, skills, turns, named, closed, id, usage.

until_steered() waits indefinitely

Give such a test a hard deadline: budget=Budget(duration=timedelta(seconds=5), graceful=False).

FakeEnvDriver ​

python
FakeEnvDriver(files: Mapping[str, bytes | str] | None = None, *,
              workdir: str | PurePosixPath = "/work", backend: EnvBackendKind | str = "local",
              provider: str = "", capabilities: Iterable[type] | None = None,
              cpu_count: int = 8, memory: int = 64 << 30, gpu_count: int = 0,
              gpu_memory: int = 0, run: Handler = None,
              refs: Iterable[str] = ("HEAD", "main"), repo: bool = True)
ParameterMeaning
filesInitial workdir contents by relative path; text written as UTF-8.
workdir, backend, providerReported location.
capabilitiesMixins served; all by default.
cpu_count, memory, gpu_count, gpu_memoryReported machine.
runAnswers exec: a table from command (argv tuple or script string) to (status, stdout, stderr), or a function (command, env) (sync or async) returning a triple or None to fall through.
refsRefs derive_worktree knows.
repoWhether the workdir is a git repository.

Defaults for unhandled commands: true, false, echo, cat, ls, sleep N and git rev-parse --is-inside-work-tree behave as expected; any other program or script returns status 127.

Attribute
filesCurrent workdir contents by relative path.
text(path)One file as text.
machineEvery file on the fake machine by absolute path.
commandsEvery command run in this workdir, in order.
clones, scratchesIds of copies and scratch directories not yet removed.

FakeOutworlder ​

python
FakeOutworlder(reply: Reply = None, *, away: bool = False)

Answers like a FakeAgentDriver (a function receives output_schema=), or is away. asked lists every prompt it was asked.

Released under the Apache-2.0 licence.