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
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)hmz exec -f twice -a builder=claude/claude-opus-5:high -p budget.cost=5 "add a --dry-run flag"Stability
| Module | Status |
|---|---|
hmz.flows | Stable. 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.flowing | Internal. 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__.
| Group | Names |
|---|---|
| Defining a flow | flow, Flow, FlowFn, load, FlowParams, FlowContext, FlowState |
| Agents | AgentCollection, Agent, Session, Outworlder, HarnessKind, HARNESS_AGENTS |
| Harness protocols | ClaudeCodeAgent, CodexAgent, CursorAgent, OpenCodeAgent, MiMoCodeAgent, MiniMaxCodeAgent, QwenCodeAgent, KimiCodeAgent, GrokBuildAgent, PiAgent, AntigravityAgent, DeepSeekHarnessAgent, LiteLLMAgent (table) |
| Fake tools and command lines | FakeTool, FakeCli, FakeCommand: hmz.coganchor.faking's own, re-exported |
| Agent mixins | FakeToolAgentMixin, GoalCommandAgentMixin, LoopCommandAgentMixin, SteeringAgentMixin, PermissionRequestHookAgentMixin, SubagentStartHookAgentMixin, SubagentStopHookAgentMixin, AskUserHookAgentMixin (table) |
| Permissions | Permission, PermissionKind |
| Budgets | Budget, Graceful, Usage |
| Environments | EnvCollection, Env, LocalEnv, EnvBackendKind, SequenceNotStr |
| Environment mixins | ShellEnvMixin, BashEnvMixin, FilesEnvMixin (table); GitWorktreeEnvMixin, TemporaryClonedDirEnvMixin, ScratchDirEnvMixin (table); RewindableEnvMixin, GitEnvMixin (table); CPUEnvMixin, MemoryEnvMixin, GPUEnvMixin, ImageEnvMixin (table) |
| Hooks | HookKind, HookFn, HookParams, HookResult, HOOK_TYPES, and eleven <Moment>HookParams/<Moment>HookResult pairs (table) |
| Errors | FlowException and the 45 classes under it |
Defining a flow
flow
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]| Parameter | Type | Default | Meaning |
|---|---|---|---|
agents | type[AgentCollection] | required | The agent roles, one key per role. See AgentCollection. |
envs | type[EnvCollection] | required | The environment roles, one key per role. See EnvCollection. |
params | type[FlowParams] | required | The params model; FlowParams itself for none. |
name | str | None | the 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_.-]*. |
description | str | None | first line of the docstring, else None | One line, shown in lists of flows. |
hidden | bool | False | Omit from every list and from /flow. The flow still runs and loads by its ref. |
resumable | bool | False | Keep 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):
| Condition | Message |
|---|---|
| not a coroutine function | a flow is an `async def` function |
| signature unreadable | its signature cannot be read |
| no positional first parameter | a 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 subclass | agents=<value> is not an AgentCollection subclass |
envs not an EnvCollection subclass | envs=<value> is not an EnvCollection subclass |
params not a FlowParams subclass | params=<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: …):
| Condition | Message |
|---|---|
| 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 mixin | an agent cannot be declared with an environment's mixins |
| agent type derives from two harness protocols | no agent is both <a> and <b> |
Outworlder type with a harness protocol or mixin | an 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 mixin | an 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
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.
| Argument | Value at run time |
|---|---|
task | str, as given to hmz exec, /flow, Hmz().run or the caller |
agents | a dict[str, AgentView | OutworlderView], one entry per role given or filled; a NotRequired role nobody gave is absent |
envs | a dict[str, EnvView], likewise |
params | an instance of the params model |
ctx | the call's FlowContext |
Flow
What @flow and load return.
| Member | Type | |
|---|---|---|
description | str | None | Its one line. |
expected_agents | type[AgentCollection] | The agent roles it declares. |
expected_envs | type[EnvCollection] | The environment roles it declares. |
expected_params | type[FlowParams] | Its params model. |
resumable | bool | Whether a run of it can be picked up. |
await flow(task, *, agents, envs, params, budget=None) | Any | Call 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
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 keys | Refused (extra="forbid"). |
budget | Not a field or alias: it is the run's, given as -p budget.<limit>=. |
| Field without a default | Must be given (-p, /flow, or a caller). |
| Cross-field constraints | Belong in a model_validator, so they are refused before the run. |
| Infinity/NaN | Serialized as the strings "Infinity"/"NaN". |
How params are read (FlowImpl.params_of), in order:
- An instance of the flow's own model is used as is.
- Any other
pydantic.BaseModelis dumped and validated into the model. - A mapping is validated. If validation fails, each value whose field failed and which is a
stris parsed as JSON (a string that is not JSON stays a string), and the mapping is validated again. This is how-pstrings reach typed fields:-p rounds=5→5,-p tags='["a","b"]'→ a list. - 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
class FlowContext(Protocol):
flow: Flow # read-only properties
budget: Budget
usage: Usage
state: FlowState | None
resumed: bool| Property | Type | Value |
|---|---|---|
flow | Flow | The flow being called. |
budget | Budget | The effective budget of this call; see Effective budget. Built on each read. |
usage | Usage | What this call and every call under it has spent. Built on each read. |
state | FlowState | None | The call's state for a resumable flow, None otherwise. |
resumed | bool | True 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
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).
| Operation | Behaviour |
|---|---|
state[key] = value | key 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 place | Not 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.
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:
| Session | Message |
|---|---|
| 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's | an 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:
| Condition | Raises |
|---|---|
| the role's agent is now another harness | UnsupportedOperation: <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 elsewhere | UnsupportedOperation: <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 place | UnsupportedOperation: <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 conversation | SessionError: 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 in | refused 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
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 type | Filled by | Required |
|---|---|---|
Agent, a subclass with mixins, or a harness protocol | -a, /flow, Hmz().run(agents=…), or a caller | yes, unless NotRequired[...] |
Outworlder | the runtime (the person at the prompt); naming it with -a is refused | never given |
"<role>" in agents tests whether a NotRequired role was given.
Agent
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 attribute | Type | Default | Meaning |
|---|---|---|---|
_permission | Permission | Permission() | What the role's sessions may touch; they run under exactly this. See Permissions. |
_skills | tuple[str, ...] | () | Skills given to the role's sessions. See Skills. |
_tools | tuple[FakeTool, ...] | () | The flow's functions the role's sessions offer the agent as tools; needs FakeToolAgentMixin. See Fake tools. |
_clis | tuple[FakeCli, ...] | () | The flow's command lines the role's sessions put on the agent's PATH; any harness but litellm. See Fake command lines. |
| Property | Type | Value |
|---|---|---|
role | str | The key it fills. |
harness | HarnessKind | The CLI. |
model | str | The model, as written in -a. |
effort | str | The effort in the CLI's own words; "" where -a said auto. |
provider | str | The account its turns run as; "" for the account this machine's CLI is signed into. |
| Method | Section |
|---|---|
spawn, run, fork | Sessions and turns |
derive | Agent.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.
| Member | Value | CLI | Protocol (HARNESS_AGENTS[kind]) |
|---|---|---|---|
CLAUDE | claude | Claude Code | ClaudeCodeAgent |
CODEX | codex | Codex | CodexAgent |
CURSOR_AGENT | cursor-agent | Cursor Agent | CursorAgent |
OPENCODE | opencode | opencode | OpenCodeAgent |
MIMO | mimo | MiMo Code | MiMoCodeAgent |
MCODE | mcode | MiniMax Code | MiniMaxCodeAgent |
QWEN | qwen | Qwen Code | QwenCodeAgent |
KIMI | kimi | Kimi Code | KimiCodeAgent |
GROK | grok | Grok Build | GrokBuildAgent |
PI | pi | pi | PiAgent |
OMP | omp | Oh My Pi | OhMyPiAgent |
AGY | agy | Antigravity | AntigravityAgent |
DSH | dsh | DeepSeek Harness | DeepSeekHarnessAgent |
LITELLM | litellm | none: 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=None | LiteLLMAgent |
ACP | acp | any CLI added on the Accounts page of /settings, driven over the Agent Client Protocol; -a names it by the name it was added under | Agent |
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.
class Builder(Agent, GoalCommandAgentMixin, PermissionRequestHookAgentMixin):
_permission = Permission(system=PermissionKind.NONE)| Mixin | Grants | Without it |
|---|---|---|
GoalCommandAgentMixin | run 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 |
LoopCommandAgentMixin | run of /loop <interval> <task>, passed to the CLI as typed: its own recurring task. | <role>: /loop needs LoopCommandAgentMixin on the role |
SteeringAgentMixin | steer. | <role>: steer needs SteeringAgentMixin on the role |
PermissionRequestHookAgentMixin | on_permission_request. | <role>: a permission_request hook needs PermissionRequestHookAgentMixin on the role |
SubagentStartHookAgentMixin | on_subagent_start. | <role>: a subagent_start hook needs SubagentStartHookAgentMixin on the role |
SubagentStopHookAgentMixin | on_subagent_stop. | <role>: a subagent_stop hook needs SubagentStopHookAgentMixin on the role |
AskUserHookAgentMixin | on_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
@dataclass(frozen=True, slots=True)
class FakeTool:
name: str
about: str
call: Callable[..., object]
takes: type[pydantic.BaseModel] | None = NoneEach 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
@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.
-a | Goal | Loop | Steering | Permission request | Subagent start, stop | Ask user | Fake tools | Fork | Rungs |
|---|---|---|---|---|---|---|---|---|---|
claude | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | any workdir | read-only, workspace-write, auto, bypass |
codex | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | any workdir | read-only, workspace-write, auto, bypass | |
kimi | ✓ | ✓ | ✓ | ✓ | any workdir | read-only, workspace-write, auto, bypass | |||
pi omp | ✓ | ✓ | ✓ | same workdir | read-only, workspace-write, auto, bypass | ||||
dsh | ✓ | ✓ | no | bypass | |||||
litellm | any workdir | read-only, workspace-write, auto, bypass | |||||||
cursor-agent | ✓ | no | read-only, workspace-write, auto, bypass | ||||||
mcode | ✓ | no | workspace-write, auto, bypass | ||||||
grok opencode mimo qwen | ✓ | same workdir | read-only, workspace-write, auto, bypass | ||||||
agy | ✓ | no | read-only, workspace-write, auto, bypass | ||||||
acp | same workdir | set 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
| When | Check | Refusal |
|---|---|---|
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 call | The same, against the drivers. | HarnessMismatch, CapabilityMissing, MissingRole, RequirementError |
| When a flow calls another | What the caller hands over covers the callee's declaration. | RequirementError leaves; see What the called flow is handed |
| At every use | The 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
@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: ...| Field | Scope | Default | Allowed |
|---|---|---|---|
local | the workdir of the environment the turn works in | ALL | NONE, READ, ALL |
user | the rest of the home directory of the user the CLI runs as | READ | NONE, READ, ALL; ≤ local |
system | everything else on the machine | READ | NONE, READ, ALL; ≤ user |
online | the network, including the CLI's own web search and fetch | ALL | NONE, ALL |
Construction coerces each field with PermissionKind(value) and raises ValueError:
| Condition | Message |
|---|---|
| field not a permission kind | <field>=<value> is not a permission kind |
local >= user >= system fails | a wider scope may not be granted more than a narrower one: local=<k>, user=<k>, system=<k> |
online is READ | online 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/etca resolver and TLS stack read,/sys(on macOS/Systemand/private/var/selectinstead 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*),/procon Linux (CUDA names a thread through/proc/self/task/<tid>/commas 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 itsTMPDIR.XDG_CACHE_HOME,UV_CACHE_DIR,npm_config_cache,PIP_CACHE_DIRandGOCACHEare 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:
| Cause | Refused where |
|---|---|
| no Landlock (Linux < 5.13), or macOS where Humanize already runs inside a sandbox | any fence other than all four scopes ALL with online ALL |
| Linux < 6.7 | online 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.
| CLI | Filesystem | Network |
|---|---|---|
claude | external; its own sandbox covers only its Bash tool and is not used | external; WebSearch, WebFetch refused by rule |
codex | external; on macOS its own sandbox, which cannot start inside the fence, is told the fence holds its commands | external; 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-agent | external; its own sandbox covers only shell commands and needs a user namespace | NONE refused: its web tools run on Cursor's servers |
mcode | external; no sandbox of its own | NONE refused: its web search runs on MiniMax's service |
opencode, mimo | external; file tools also refuse outside the fence | external; web tools removed offline |
qwen | external | external; web_search, web_fetch withheld |
kimi | external | external; its daemon may bind its one port |
grok | external; its own sandbox writes /tmp | external; --disable-web-search offline |
pi | external | external; started --offline; gateways its models.json declares stay reachable |
omp | external | external; never started --offline; the baseUrl of the model's provider in its models.yml stays reachable |
agy | external; its --sandbox covers only commands | external; web tools removed offline (and always with online NONE) |
dsh | external | external |
acp | external, plus the state declared for it | external, to the hosts declared for it; NONE with none declared refused |
"External" is Landlock (Seatbelt on macOS) plus the loopback proxy.
The rung.
local | every harness but dsh, mcode, acp | dsh, mcode, acp |
|---|---|---|
READ or NONE | read-only (Claude Code plan, Codex read-only sandbox, a tool list without writers) | bypass |
ALL | bypass | bypass |
bypassis the CLI's no-approval mode: Codexdanger-full-accesswith approvalnever; Claude CodebypassPermissions. 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 CodeacceptEdits, Codex theworkspace-writesandbox with approvalon-request.- A hook only an asking CLI can deliver changes how it is started. Claude Code runs at
manualinstead ofbypassPermissionswhileon_permission_requestis hung. Codex runs with approval policyuntrustedwhileon_permission_requestis hung, and enables itsdefault_mode_request_user_inputfeature whileon_ask_useris hung. Kimi Code runs at coganchor'sautorung (Kimi'syolo) while either is hung. Humanize answers yes unless the hook says no. onlinealso switches the CLI's own web tools (on forALL, off forNONE) 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
def derive(self, *, permission: Permission | None = None,
skills: tuple[str, ...] | None = None) -> Self| Parameter | Default | Meaning |
|---|---|---|
permission | this agent's | The derived agent's permission. Must be covered by this agent's. |
skills | all of this agent's | Which 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).
| Condition | Raises |
|---|---|
permission not a Permission | TypeError: permission=<value> is not a Permission |
permission wider | CapabilityNotGranted: <role>: <permission> is wider than the <granted> this agent was granted |
| a skill not granted | CapabilityNotGranted: <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
async def spawn(self) -> SessionOpens 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.
| Condition | Raises |
|---|---|
| the call's caller or the run has ended | FlowCancelled |
Agent.run
async def run(self, prompt: str, *, session: Session, env: Env | None = None,
output_schema: type[M] | None = None,
budget: Budget | None = None) -> str | M| Parameter | Type | Meaning |
|---|---|---|
prompt | str | The prompt. /goal … and /loop … need their mixins. |
session | Session | A session of this agent (or of an agent derived from it). |
env | Env | None | Where 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_schema | type[pydantic.BaseModel] | None | Return an instance of this model instead of text. |
budget | Budget | None | A 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:
- Capability check for
/goal,/loop; budget check (a spent budget raises itsBudgetExceededleaf before anything is sent). - The session's CLI is started in
env(first turn) or the session is carried toenv(later turns), as above. - On the session's first turn only:
SESSION_START. A non-emptycontextis prepended to the prompt, separated by a blank line. USER_PROMPT_SUBMITwith the prompt as given.block=TrueraisesSessionError(reason)(a hook refused the promptfor an empty reason). A non-emptycontextis appended after a blank line.- The CLI takes the turn. Steers queued before it starts are appended, each after a blank line.
- If no steer is pending:
STOPwithsaidandagain.block=Truewith a non-emptyreasonsendsreasonas the next prompt of the same turn and incrementsagain. An emptyreasondoes not block. - Steers that arrived during the CLI's turn are joined with blank lines and sent as the next prompt of the same turn.
- 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>.
| Condition | Raises |
|---|---|
session not this agent's | SessionError: <role>: <session> is not one of this agent's |
env not an environment the run handed out | TypeError: <env> is not an environment this run handed out |
env given to a litellm agent | UnsupportedOperation: <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 way | SessionError: <role>: a turn of this session is under way |
| the session is closed | SessionError: <role>: the session is over |
| the call's caller or the run has ended | FlowCancelled |
| the agent's driver is closed | SessionError: the agent's driver is closed |
| a local workdir that is not a directory | SessionError: <dir> is not a directory to open a session in |
| CLI not installed where its harness runs | HarnessNotInstalled |
| the fence cannot be held | HarnessSandboxed |
| the CLI cannot be configured as the session asks | HarnessUnrecoverable |
env on another machine than the session's last turn | UnsupportedOperation: <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 place | UnsupportedOperation: <harness> cannot move a session into another workdir |
| a fork's first turn, its parent having taken a turn since the fork | SessionError: <role>: the session it was forked from has taken a turn since; … |
| the turn was interrupted | SessionError: the turn was interrupted |
| a budget over the turn is spent | the BudgetExceeded leaf |
| a hard (not graceful) limit reached mid-turn | the BudgetExceeded leaf; the CLI stops |
| the CLI failed the turn | the HarnessError leaf |
the task running run is cancelled | asyncio.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
async def steer(self, prompt: str, *, session: Session, queued: bool = True) -> None| Parameter | Default | Meaning |
|---|---|---|
prompt | Text put into the turn. | |
session | The session taking the turn. | |
queued | True | True: 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
async def fork(self, session: Session) -> SessionOpens 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.
| Condition | Raises |
|---|---|
session not this agent's, or closed | SessionError (<role>: the session to fork is over) |
session has taken no turn | SessionError: <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:
| Condition | Raises |
|---|---|
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 turn | UnsupportedOperation: <harness> cannot fork a session onto another machine |
the turn's env another workdir, on a harness that forks only in place | UnsupportedOperation: <harness> cannot fork a session into another workdir |
| the parent has taken a turn since the fork | SessionError: <role>: the session it was forked from has taken a turn since; … |
| the CLI cannot cut the fork | SessionError: the session cannot be forked: <reason> |
The parent is held open until the fork's first turn. See Branching a conversation.
Session
class Session(Protocol):
agent: Agent # read-only properties
usage: Usage| Property | Value |
|---|---|
agent | The agent whose conversation this is. |
usage | What 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.
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)| Method | Moment | Needs | Delivered |
|---|---|---|---|
on_session_start | a session's first turn is about to start | by the driver, before the turn | |
on_user_prompt_submit | a prompt is about to be sent | by the driver, before each turn | |
on_pre_tool_use | the agent reached for a tool | from inside the CLI; see below | |
on_notification | the agent stopped to tell its user something | from inside the CLI | |
on_stop | a CLI turn ended and the turn is about to end | by the driver | |
on_session_end | the session is closing | by the driver | |
on_permission_request | the harness asks whether a tool may run | PermissionRequestHookAgentMixin | from inside the CLI |
on_subagent_start | the agent started a subagent | SubagentStartHookAgentMixin | from inside the CLI |
on_subagent_stop | a subagent is about to finish | SubagentStopHookAgentMixin | from inside the CLI |
on_ask_user | the agent asks its user a question mid-turn | AskUserHookAgentMixin | from inside the CLI |
on_outworlder_run | an outworlder made with Outworlder.new() is asked to take a turn | an 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).
HookKind | Params fields | Result fields (default) |
|---|---|---|
SESSION_START | context: str = "" | |
USER_PROMPT_SUBMIT | prompt: str | block: bool = False, reason: str = "", context: str = "" |
PRE_TOOL_USE | tool: str, input: Mapping[str, Any] | block: bool = False, reason: str = "" |
PERMISSION_REQUEST | tool: str, input: Mapping[str, Any] | allow: bool = True, reason: str = "" |
NOTIFICATION | message: str | |
STOP | said: str, again: int = 0 | block: bool = False, reason: str = "" |
SESSION_END | ||
SUBAGENT_START | subagent: str, task: str = "" | context: str = "" |
SUBAGENT_STOP | subagent: str, said: str = "" | block: bool = False, reason: str = "" |
ASK_USER | question: str, options: tuple[str, ...] = () | answer: str | None = None |
OUTWORLDER_RUN | prompt: str, output_schema: type[BaseModel] | None = None | output: 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.
| Field | Meaning |
|---|---|
tool, input | The 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, again | The agent's last text, and how many times a STOP hook has already kept this turn going. |
subagent, task | The harness's name for the subagent, and its task. |
question, options | The question and the offered answers. answer need not be one of them; None leaves it unanswered and the agent continues without one. |
context | Text added to the prompt (see run). For SUBAGENT_START it is delivered to no CLI. |
block, reason | See 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, reason | allow=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). |
output | What the outworlder's run returns: text, or an instance of output_schema. |
Rules.
- Hanging a hook replaces the one hung on that moment;
Noneremoves 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;
runraises it. on_pre_tool_usecan 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_userhas 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_useon a gating CLI, andon_permission_requestoron_ask_useron 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_useoron_permission_requestis hung is refused where its runtime's harness isenv, 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
class HookFn[TParams: HookParams, TResult](Protocol):
async def __call__(self, params: TParams, /) -> TResult: ...HookParams and HookResult
@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
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).
| Member | Behaviour |
|---|---|
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. |
away | True 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. |
fork | UnsupportedOperation: 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
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
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: ...| Member | Value |
|---|---|
workdir | Where 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. |
backend | local, 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. |
available | Whether the machine was reachable and the workdir existed, as last probed. |
role | The 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
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
| Mixin | Adds | Without it |
|---|---|---|
ShellEnvMixin | async 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 |
BashEnvMixin | exec(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 |
FilesEnvMixin | async 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
| Mixin | Class attribute | Default | Meaning |
|---|---|---|---|
CPUEnvMixin | _cpu_count: int | 1 | Minimum logical CPUs. |
MemoryEnvMixin | _memory: int | 0 | Minimum memory, bytes. |
GPUEnvMixin | _gpu_count: int, _gpu_memory: int | 1, 0 | Minimum 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
| Mixin | Methods |
|---|---|
GitWorktreeEnvMixin | async derive_worktree(*, ref: str | None = None, dir: PurePosixPath | str | None = None) -> Self |
TemporaryClonedDirEnvMixin | async derive_temp_clone(id: str) -> Self, async destroy_temp_clone(id: str) -> None |
ScratchDirEnvMixin | async 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.
| Method | Behaviour | Raises |
|---|---|---|
derive_worktree | git 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
| Mixin | Methods |
|---|---|
RewindableEnvMixin | async snapshot(name: str | None = None) -> str, async rewind(ref: str) -> None, async snapshots() -> list[str] |
GitEnvMixin | the 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.
| Method | Behaviour |
|---|---|
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 noneBudgets
Budget
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| Field | Limits |
|---|---|
duration | Wall-clock time from the start of the call (or turn) the budget belongs to: a deadline. |
cost | USD spent by turns under it. |
output_tokens | Tokens written by agents under it. |
graceful | True: 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.
durationis 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.budgetreports, for each limit, the least over the chain oflimit − spent under that call + spent under this call(the total this call may reach),durationas 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;gracefulis aboolwhere the three agree and aGracefulwhere they do not.costisNonewhere 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=Falseto cut a turn short.
Enforcement.
| Situation | Result |
|---|---|
| Turn requested with a spent limit | The BudgetExceeded leaf, before anything is sent: <ref>: its budget's duration is spent / cost is spent / output tokens are spent. |
| Limit reached mid-turn, graceful | The turn completes and returns; the next turn raises. |
| Limit reached mid-turn, hard | The CLI is stopped; run raises the leaf. |
| A call's deadline passes | The 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 above | The 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
class Usage(pydantic.BaseModel): # frozen=True, extra="forbid"
duration: timedelta = timedelta(0)
cost: float = 0.0
output_tokens: int = 0duration 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
runwhile one is under way raisesSessionError. - 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.
| Refusal | Message |
|---|---|
| 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}.
t | Fields | Written |
|---|---|---|
call | id (int), parent (int, 0 for the top call), digest (hex), seq (int), ref (canonical ref) | when a call starts; batched |
set | id, key, value | on each state write; flushed immediately. A session in value is {"\u0000session": {"harness", "id", "at", "role"}}, at being its copy |
del | id, key | on each state delete; flushed immediately |
session | id, role, harness, model, session (the CLI's id) | once the CLI names the session (at open or during the first turn); batched |
tmp | id, env (role), kind (temp_clone | scratch), name (the id), chain, workdir | when a copy or scratch directory is made; batched |
end | id, 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.
| Role | Spec in the digest |
|---|---|
| agent | <harness>@<provider>/<model>:<effort>|<local>,<user>,<system>,<online>|<skill,skill…> |
| outworlder | outworlder, or outworlder:new for one from Outworlder.new() |
| environment | its 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.
| Call | On resume |
|---|---|
| the top call | Picks up the earlier top call unconditionally: ctx.resumed is True, state restored. |
| a call under a call that picked up | Picks 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 flow | Has 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
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
def load(ref: str) -> FlowReturns 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.
| Raises | When |
|---|---|
FlowRefError (a ValueError) | not a ref; :<name> with no flow asking (':review' is relative to the flow asking, and no flow is asking) |
FlowNotFound | the ref names no flow; a git+ repository cannot be fetched or has no flow where the ref says |
FlowLoadConflict | importing the module would replace a module another flow of a running run uses |
FlowDefinitionError | the named module or flow is written wrong, or its import raised (importing the flow at <dir> failed: <error>) |
Refs
| Form | Names | Resolved |
|---|---|---|
:<name> | a flow in the same module as the code calling load | only 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 directory | that 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 agit+ref is a name, of at most<user>/<flow>after an optional@<flowverse>/; one with a part too many, empty or hidden is aFlowRefError('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 agit+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: schemehttps,http,ssh,fileorgit; a host is required except forfile;@<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 onsys.path, so modules and packages beside__init__.pyimport 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.pyfiles 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 insys.modulesby something that is not a flow raisesimporting <dir> would replace the module '<name>' (<file>). Flows kept in directories of one name --alice/kernelandbob/kernel, or@a/loopand@b/loop-- claim the same name, so one run cannot call both. - A module's flows are the
@flowobjects defined in files inside its directory; one imported from elsewhere is not one of its flows.
Calling a Flow
async def __call__(self, task: str, *, agents: Mapping[str, Agent],
envs: Mapping[str, Env], params: FlowParams,
budget: Budget | None = None) -> Any| Parameter | Meaning |
|---|---|
task | The callee's task. |
agents | One agent per callee role, keyed by the callee's role names. |
envs | One environment per callee role. |
params | An instance of the callee's model is used as is; another model or a mapping is validated (rules). |
budget | A 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.
| Condition | Raises |
|---|---|
| called outside any run | FlowRuntimeError: <ref>: a flow is called from inside a run; start one with run_flow |
| the caller (or any call above, or the run) has ended | FlowCancelled: <ref>: <caller ref | the run> has ended |
| depth would exceed 64 | FlowDepthExceeded (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 role | Requirement | Refusal |
|---|---|---|
| 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 role | filled with the run's outworlder | — |
nothing, LocalEnv role | filled with the run's workspace, granted the callee's declaration | CapabilityMissing if the workspace lacks a declared mixin |
| an agent | carries every mixin the role declares | CapabilityMissing: <ref>: '<role>' needs <mixins>, which the agent given was not granted |
| an agent | its permission covers the role's _permission | PermissionTooNarrow: <ref>: '<role>' needs <p>, and the agent given holds <q> |
| an agent, harness-protocol role | the same harness | HarnessMismatch: <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 environment | carries every mixin the role declares | CapabilityMissing: <ref>: '<role>' needs <mixins>, which the environment given was not granted |
| an environment | meets the role's resources | ResourceUnmet |
an environment, LocalEnv role | on this machine | CapabilityMissing (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>.
@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=Trueflows 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.mdNames 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:
| Order | Place | Directory |
|---|---|---|
| 1 | local | .hmz/flows/ under the current directory |
| 2 | user | ~/.hmz/flows/ (literally ~, not HUMANIZE_HOME) |
| 3 | official | the package's hmz/flows/builtin/, then ~/.hmz/flowverses/official/installed/ (under HUMANIZE_HOME where it is set) |
| 4 | other 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:
| Where | Message |
|---|---|
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 as | Is |
|---|---|
chat, flame_chasoid | built in, or installed from official's flows/<flow>/ |
alice/kernel | installed from official's flows/alice/kernel/ |
@theirs/review | installed from flowverse theirs |
@theirs/alice/kernel | installed from flowverse theirs's flows/alice/kernel/ |
@local/chat | this 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.
class Reviewer(Agent):
_skills = ("review-notes", "https://github.com/you/review-skills#writing-tests")| Entry | Is |
|---|---|
| a name | skills/<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'
_skillswrite as string literals into the installed flow's ownskills/<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.
| Manifest | Lists | Index name |
|---|---|---|
flows/<flow>/<version>/flow.yaml | a flow listed bare: in official, humanfia's own | <flow> |
flows/<user>/<flow>/<version>/flow.yaml | a 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>/).
| Flowverse | Location | Fetched | Removable |
|---|---|---|---|
official | package 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 demand | no |
local | .hmz/flows (relative to the current directory) | never | no |
user | ~/.hmz/flows | never | no |
| any other | what is installed from the clone at ~/.hmz/flowverses/<name>/index/ | clone on add; in the background each time hmz starts; on demand | yes |
Order. Listed: official, others alphabetically, local, user. Looked up: local, user, then the listed order.
| Operation | Behaviour | Errors |
|---|---|---|
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
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"| Key | Type | Required | Rule |
|---|---|---|---|
name | str | yes | [a-z][a-z0-9_]*; equal to the <flow> directory; listed bare, not a built-in flow's name |
version | str | yes | SemVer 2.0.0; equal to the <version> directory |
description | str | no | one line |
repo | str | yes | owner/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 |
ref | str | no | the tag or branch the release was cut from; informational |
commit | str | yes | 40 hex digits (case-folded); what is installed |
subdir | str | no | the directory holding the flow (__init__.py, or <name>.py); "" or . for the root; no part hidden or climbing |
license | str | no | SPDX identifier |
dependencies | {flow: range} | no | flows 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>:
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:Refusal Message no such flowverse, not an index, not fetched no flowverse called '<verse>',<verse> is a directory of your own flows, not an index,<verse> has not been fetched yet; fetch it firstno 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 rangetwo ranges for one flow that one release cannot meet <flow> <version> needs <dep> <range>, and <dep> <v> is what the rest of this install needsanother installed flow's range broken <other> <v> needs <flow> <range>; installing <flow> <version> would break ita flow and a user of one name, one inside the other alice would be installed where alice/kernel is, out of <verse>; uninstall alice/kernel firstCopy, 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 atcommit(the same cached checkout), andsubdiris copied, without.gitand__pycache__, into.<flow>.XXXXXXXXbeside the target; a single<flow>.pybecomes that directory's__init__.py..installed.jsonis 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.
| Flow | Agent roles | Roles need | Resumable | Budget | Grant |
|---|---|---|---|---|---|
chat | assistant, human: Outworlder | Budget(cost=math.inf) unless one is given | every mixin the harness serves | ||
ralph_loop | agent | required | what each role declares | ||
stateful_ralph | agent | ✓ | required | what each role declares | |
continue_loop | agent | required | what each role declares | ||
goal | worker | GoalCommandAgentMixin | required | what each role declares | |
flame_chase | first_chaser, second_chaser | ✓ | required | what each role declares | |
rlar | actor, reviewer | ✓ | required | what 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.
| Flow | Agent roles | Roles need | Resumable |
|---|---|---|---|
humanize1:gen-idea | drafter | ||
humanize1:gen-plan | planner, analyst | ||
humanize1:rlcr | builder, reviewer, human | builder: PermissionRequestHookAgentMixin | ✓ |
flame_chasoid:parallel | coordinator, lane_1_actor_a … lane_3_actor_b, human | ✓ | |
flame_chasoid:parallel_git_pr | orchestrator, lane_1_actor_a … lane_3_actor_b, human | ✓ | |
flame_chasoid:agent_cleanup_ralph_loop | agent, cleaner, human | SteeringAgentMixin on both agents | ✓ |
flame_chasoid:agent_cleanup_flame_chase | first_chaser, second_chaser, cleaner, human | SteeringAgentMixin on all three agents | ✓ |
recursive_lean_prover | worker, reviewer | worker: PermissionRequestHookAgentMixin | ✓ |
aot | writer, critic, human |
Command-line specs
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>]| Part | Rule |
|---|---|
<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. |
| Input | Message |
|---|---|
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 at | HarnessUnrecoverable: <spec>: <reason> |
| an added CLI not added on this machine | HarnessNotInstalled: <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.
| Form | Is |
|---|---|
local/abs/path | a directory on this machine; local takes no provider |
ssh@<name>/abs/path | a directory on the host the ssh runtime saved as <name> reaches |
ssh@[<host>]/abs/path | a 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>]/~/path | under the login's home there (the leading / before ~ is dropped) |
docker/abs/path, docker@<name>/abs/path | a 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/path | a 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/path | an 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/path | a 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 |
| Input | Message |
|---|---|
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 short | refused 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.
| Input | Message |
|---|---|
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 validate | the 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.
| Limit | Accepted | Examples |
|---|---|---|
budget.duration | seconds (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.cost | USD, optional leading $; inf for none; ≥ 0, not NaN | budget.cost=$20 |
budget.output_tokens | whole number, _ ignored, suffix k (×1000) or m (×1 000 000), decimals allowed if the result is whole | budget.output_tokens=1.5m |
budget.graceful | 1 true yes on / 0 false no off (case-insensitive), for every limit | budget.graceful=false |
budget.graceful.duration, budget.graceful.cost, budget.graceful.output_tokens | as budget.graceful, for that limit alone, over budget.graceful | budget.graceful.output_tokens=false |
| Input | Message |
|---|---|
| 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.
| Work | Harness |
|---|---|
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 local | here, 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.
| Condition | Refusal |
|---|---|
| a harness runtime, role not granted everything | HarnessSandboxed: <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 container | the runtime's EnvUnavailable, EnvConnectionError or ResourceUnmet |
env: CLI missing | HarnessNotInstalled: <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 holdable | HarnessSandboxed: <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 answer | HarnessUnrecoverable: <backend>[@<provider>] did not say within 300s whether <cli> is there, or could not be asked whether <cli> is there: <reason> |
env: gating hook hung | UnsupportedOperation: 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:
| Condition | Message |
|---|---|
| duplicate role across forms | <flow>: duplicate agent role '<role>' / duplicate environment role |
| a flow that will not load | the FlowNotFound / FlowRefError / FlowDefinitionError / FlowLoadConflict text |
--resume refusals | see 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:
| Means | Effect |
|---|---|
| a budget | see Enforcement |
| ctrl+c | once 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 TimeoutErrorcatchesEnvCommandTimeoutandDurationExceeded. - 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. HarnessThrottledandHarnessDroppedare transient;BudgetExceeded,HarnessRefusedandModelUnavailablerecur on retry.
| Class | Bases | Raised when |
|---|---|---|
FlowException | Exception | root |
FlowRuntimeError | FlowException | Humanize refused something, or a limit ran out; also a flow called outside a run |
FlowNotFound | FlowRuntimeError | a ref names no flow; a git+ repository cannot be fetched |
FlowRefError | FlowRuntimeError, ValueError | a ref is malformed, or relative with nothing to be relative to |
FlowDefinitionError | FlowRuntimeError | a flow is written wrong (tables); its module fails to import; a skill a role names is missing |
FlowLoadConflict | FlowRuntimeError | importing would replace a module a running run uses |
RequirementError | FlowRuntimeError | what 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 |
MissingRole | RequirementError | a required role was not given |
CapabilityMissing | RequirementError | an 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 |
PermissionTooNarrow | RequirementError | an agent's permission does not cover its role's |
ResourceUnmet | RequirementError | a machine has fewer CPUs/GPUs or less memory than declared, or a docker runtime has not that much left |
HarnessMismatch | RequirementError | a harness-protocol role got another harness |
CapabilityNotGranted | FlowRuntimeError | an operation the role did not declare; widening a grant with derive; on_outworlder_run on an outworlder not from new() |
ParamsError | FlowRuntimeError, ValueError | params do not validate |
FlowDepthExceeded | FlowRuntimeError, RecursionError | a call would be 65 deep |
BudgetExceeded | FlowRuntimeError | a budget of this call or one above is spent; sticky |
DurationExceeded | BudgetExceeded, TimeoutError | duration elapsed |
CostExceeded | BudgetExceeded | cost spent |
OutputTokensExceeded | BudgetExceeded | output_tokens spent |
FlowCancelled | FlowRuntimeError | the run, or a caller above, has ended while this call still runs |
StateNotSerializable | FlowRuntimeError, TypeError | a ctx.state key is not a str, or a value is not JSON-serializable |
OutworlderAway | FlowRuntimeError | an away outworlder was asked for a schema with a field without default |
HarnessError | FlowException | a CLI could not take a turn or do what was asked |
HarnessNotInstalled | HarnessError | the CLI (or its SDK) is not installed where its harness runs; a command that exits 127 |
HarnessContended | HarnessError | two turns reached one local store of the CLI at once, and this one lost |
HarnessThrottled | HarnessError | rate limit or spent quota |
HarnessRefused | HarnessError | the provider refused the credential |
ModelUnavailable | HarnessError | the model is not served to this account, retired, or unknown |
HarnessMissing | HarnessError | the CLI would not start |
HarnessSandboxed | HarnessError | the fence cannot be held (by the CLI or the machine) |
HarnessKilled | HarnessError | the CLI died mid-turn (signal, OOM) |
HarnessDropped | HarnessError | the connection to the CLI or provider broke mid-turn; any OSError from a turn |
HarnessUnrecoverable | HarnessError | any other failure no retry would change; a CLI that cannot be configured as asked |
OutputSchemaError | HarnessError, ValueError | the answer is not the output_schema |
SessionError | HarnessError | a 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 |
UnsupportedOperation | HarnessError | the 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 |
EnvError | FlowException | an environment could not do what it was asked |
EnvUnavailable | EnvError | the machine is gone or the workdir does not exist |
EnvConnectionError | EnvError, ConnectionError | the connection to a remote environment failed |
EnvCommandTimeout | EnvError, TimeoutError | exec ran past its timeout and was killed |
EnvFileNotFound | EnvError, FileNotFoundError | read of a missing file |
EnvPermissionDenied | EnvError, PermissionError | a read, write or command refused for want of permission |
WorktreeError | EnvError | derive_worktree failed |
TempCloneBusy | EnvError | derive_temp_clone of an id another environment holds |
ScratchError | EnvError | a scratch directory could not be made or removed |
RewindError | EnvError | snapshot, 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.
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) == 3run_fake
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| Parameter | Default | Meaning |
|---|---|---|
flow | A Flow, or a ref loaded relative to the caller as load does. | |
task | "" | The task. |
agents | None | Per 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. |
envs | None | Per role: a driver, or the initial files of a FakeEnvDriver. An omitted role gets an empty one large enough for its declared resources. |
params | defaults | Params or a mapping. |
budget | unlimited | The run's budget. |
outworlder | an away one | Fills Outworlder roles. |
local | FakeEnvDriver(workdir="/here") | Fills LocalEnv roles. |
journal | None | Path of a resumable flow's journal. |
resume | False | Pick up journal. |
recorder | None | internal 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
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)| Parameter | Meaning |
|---|---|
harness | Which harness; serves that harness's mixins unless capabilities is given. |
reply | One 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, provider | Reported identity. |
capabilities | Mixins served, overriding the harness's. |
cost, output_tokens, seconds | Reported per answer; nothing waits. |
forks | Whether 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_late | Sessions have no id until their first turn gets past its hooks. |
| Attribute | |
|---|---|
prompts | Every prompt any session was given, including hook context and STOP reasons. |
sessions | Every FakeSession, in order. |
live, peak | Sessions open now; the most open at once. |
closed | How 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.
| Method | Fires |
|---|---|
await tool(name, input=None) -> bool | PRE_TOOL_USE, then PERMISSION_REQUEST where served. Returns whether the tool would run. |
await ask(question, options=()) -> str | None | ASK_USER; returns the answer. UnsupportedOperation where AskUserHookAgentMixin is not served. |
await notify(message) | NOTIFICATION. |
await subagent(name, task="", said="") -> str | SUBAGENT_START, SUBAGENT_STOP where served. |
await until_steered() -> str | Waits for a steer and returns it. |
await use(name, input=None) -> str | Calls 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
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)| Parameter | Meaning |
|---|---|
files | Initial workdir contents by relative path; text written as UTF-8. |
workdir, backend, provider | Reported location. |
capabilities | Mixins served; all by default. |
cpu_count, memory, gpu_count, gpu_memory | Reported machine. |
run | Answers 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. |
refs | Refs derive_worktree knows. |
repo | Whether 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 | |
|---|---|
files | Current workdir contents by relative path. |
text(path) | One file as text. |
machine | Every file on the fake machine by absolute path. |
commands | Every command run in this workdir, in order. |
clones, scratches | Ids of copies and scratch directories not yet removed. |
FakeOutworlder
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.