Skip to content

Hooks

A hook is a Python callable hung on a moment, one of the points a turn passes through. Reach for one when you want to get between an agent and its turn: refuse a command, add to a prompt, or refuse to let a turn end. Claude Code, Codex and Kimi Code each take a table of shell commands for the same moments; a hook is the same idea, hung on a live agent, taken down again while it runs, and written in the language the flow is.

Try it

The gentlest hook does nothing but look. This one says what its agent reached for:

python
# .humanize/flows/watched/__init__.py
"""A Ralph loop that says what its agent reached for."""

from hmz.flows import Agent, Moment, Occasion, Verdict, flow


def seen(occasion: Occasion) -> Verdict | None:
    print(f"  → {occasion.tool}: {occasion.about[:60]}")
    return None                          # None says nothing


@flow
def run(agents: tuple[Agent], task: str) -> None:
    (agent,) = agents
    with agent.hooks.on(Moment.PRE_TOOL_USE, seen):
        for _ in range(5):
            agent(task, suppress=True)
sh
hmz exec -f watched -a claude/claude-opus-4-8:high "$(cat TASK.md)"

Each time the agent reaches for a tool you get a line: an arrow, the tool name, and the first 60 characters of what it was about to do. The hook returns None, so the turn goes on unchanged.

What a flow prints goes into the transcript

The interface captures everything printed under it, so a print is how a flow says something.

Hanging and taking down

on answers with a handle, and the handle is also a context manager:

python
with agent.hooks.on(Moment.STOP, keep_going):
    agent(task)              # and it is down again after the block
python
hung = agent.hooks.on(Moment.STOP, keep_going)
hung.off()                   # by hand; taking down what is already down is not an error

Hooks are on the agent, so one covers every session the agent holds, including the fresh one a Ralph loop makes each turn. Hanging one mid-run is the point.

The moments

MomentWhenWhat a verdict does
SESSION_STARTa session is about to take its first turn
USER_PROMPT_SUBMITa prompt is about to go to the agentrefused skips the turn; adds goes into the prompt
PRE_TOOL_USEthe agent has reached for a tool
PERMISSION_REQUESTthe backend is asking whether a tool may runrefused denies it, with because as the reason
NOTIFICATIONthe agent has stopped to ask its user something
STOPa turn has endedrefused sends the agent on, with because as the next prompt
SESSION_ENDa session has been closed

A hook is told an Occasion, which carries moment, agent, session, prompt, tool, about, input, said and again. It answers with a Verdict or with None, and None says nothing. Two hooks on one moment are one verdict: refused if either refused, and adding everything either added.

A verdict can refuse a command:

python
from hmz.flows import Moment, Occasion, Verdict

def no_force_push(occasion: Occasion) -> Verdict | None:
    if "push --force" in occasion.about:
        return Verdict(refused=True, because="not on this branch")
    return None

agent.hooks.on(Moment.PERMISSION_REQUEST, no_force_push, tool="Bash")

It can also add to a prompt:

python
def remind(occasion: Occasion) -> Verdict | None:
    return Verdict(adds="Run the tests before you say you are done.")


with agent.hooks.on(Moment.USER_PROMPT_SUBMIT, remind):
    ...

A refused STOP is a goal by hand

The turn is not over until the hook lets it be. occasion.again counts how many times this turn has already been sent on, so a hook that keeps refusing can decide to stop:

python
def keep_going(occasion: Occasion) -> Verdict | None:
    if occasion.again < 3 and "TODO" in Path("TASK.md").read_text():
        return Verdict(refused=True, because="There is still a TODO in TASK.md.")
    return None

That is what official/humanize1:rlcr is built on: a round is the builder believing the plan is done and trying to stop, and what the reviewer says is what it hears instead.

It is one of three ways to keep an agent going:

Decides it is doneWorks on
a while loop in the flowyour code, between turnsevery backend
a refused STOP hookyour code, inside the turnevery backend but Person
agent.pursuethe model, against the objectiveClaude Code, Codex, DeepSeek Harness, Kimi, ZCode

Not every backend runs every moment

agent.moments lists what a backend runs. If a moment is not in it, hooks.on refuses the hook where it is hung, rather than hanging one that quietly never fires.

MomentClaude CodeCodexKimi CodeZCodeyou
everything except PERMISSION_REQUESTyesyesyesyesno
PERMISSION_REQUESTyesyesnoyesno

Claude Code, Codex and ZCode ask before they use a tool and wait for the answer, so those are the three where a refusal reaches the agent. Kimi Code, pi, opencode and mimocode are driven unattended — a flow watches its agent rather than gating it. Person runs none of the moments: a moment is a point in a turn of a model, and the person takes no such turn.

PERMISSION_REQUEST also wants the auto rung, the one setting under which a backend asks and waits.

Saying so in the flow

A flow that hangs a hook on a moment only some backends run declares the moment beside the type, and is refused before its first turn:

python
from typing import Annotated, NamedTuple

from hmz.flows import Agent, Moment

class Agents(NamedTuple):
    """The two this drives: one that is gated, and one that reads its work."""

    builder: Annotated[Agent, Moment.PERMISSION_REQUEST]
    reviewer: Agent
console
$ hmz exec -f gated -a kimi/kimi-code/k3:high -a kimi/kimi-code/k3:high "fix the build"
hmz exec: error: gated: builder has to run PermissionRequest, which kimi does not

The agents page of /flow then offers only the CLIs that would work for that place.

Two rules

A hook that raises has said nothing. A flow must not fail because something hung off it did. The one exception is a hook that drove an agent which has been stopped: it lets Stopped out, so a run ended by hand reads as ended by hand.

A hook runs on the turn's own thread. One that takes a while is a turn that takes a while. Do not run a test suite in a PRE_TOOL_USE hook.

See also

Released under the Apache-2.0 licence.