Skip to content

Using Hooks

Hooks let code observe a Wind Tunnel run at fixed lifecycle points and emit sidecar artifacts. They are deliberately not a second scoring system. A hook can watch, ask one post-score follow-up turn, and write JSON through the runner; it cannot change the trace, reset the agent, edit steering, or decide whether the run passed.

Quickstart: debrief failed runs

Turn on the built-in debrief hook with --hook debrief:

uv run wt run --runtime <your-runtime> --scenario <scenario-name> --runs 1 --hook debrief

wt run still writes the normal trace and score sidecar:

runs/.../<stem>.json
runs/.../<stem>.score.json

When debrief fires, it writes one more sidecar beside them:

runs/.../<stem>.debrief.json

The debrief artifact contains the run identity, scenario id, agent/model labels, headline verdict, failed score layers, each layer's reason string, the exact diagnostic prompt sent back to the agent, the agent's reply, timeout/error metadata, and tools_disabled: false. That last field is intentional: hook conversation uses the same session and runtime send() path, and the current SPI cannot disable tools for that extra turn. The scored trace is already frozen before the debrief happens, so this cannot change the verdict, but the artifact should still say what happened.

By default, debriefs are emitted only for runs with at least one failed score layer. To include passing runs too:

WT_DEBRIEF_ON=all uv run wt run --runtime <your-runtime> --runs 1 --hook debrief

The follow-up turn has a framework-owned deadline. The default is 30 seconds; override it when your runtime has a slower worst-case response path:

WT_HOOK_CONVERSE_TIMEOUT_S=90 uv run wt run --runtime <your-runtime> --runs 1 --hook debrief

How to read a debrief

Read a debrief as a lead, not a verdict.

It is strongest when the model quotes concrete tool or environment evidence it actually received: schema errors, missing capabilities, malformed results, timeouts, empty tool responses, or surprising response shapes. The model had privileged observability of those strings from inside the run; the scorer may only see their effect.

It is weakest when it explains steering decisions after the fact. Questions like "why did you not clarify?" invite confabulation. That is why the debrief prompt asks for environment and tool errors first, blockers second, and only then asks what the agent would do differently. If the reply is short, lazy, or truncated, the first section is the part most likely to be useful.

Debriefs never gate. They do not change Score, JUnit output, report totals, or the wt run exit code. Wind Tunnel also never consumes a debrief to auto-edit a system prompt, skill, scenario, or runtime. Use it in the triage ladder: wiring, harness, steering, model. Do not let a model's self-report rewrite the bench that judged it.

Writing your own hook

Subclass Hook and override only the lifecycle points you need:

from windtunnel.spi.hooks import Hook, HookContext


class RunNotesHook(Hook):
    name = "run_notes"

    def on_run_scored(self, ctx: HookContext) -> None:
        ctx.emit_artifact({
            "schema_version": 1,
            "run_id": ctx.run_id,
            "scenario_id": getattr(ctx.scenario, "name", ""),
            "outcome_passed": bool(ctx.score and ctx.score.outcome.passed),
        })

Hook names and artifact labels must be filesystem-safe slugs matching [a-z0-9_-]+. The name is both the --hook activation token and the artifact filename discriminator.

Lifecycle point When it fires What you can rely on
on_provisioned Once per scenario batch, after provision() and world preconditions The handle exists; no run has started; no trace or score exists yet.
on_run_start Per run, after reset_state() and surface capture, before the first inject The session is fresh and session_id is minted; nothing has been sent.
on_run_scored Per run, after Score is computed The trace, MCP evidence, observations, and score are frozen; the session is still alive; ctx.converse() is valid.
on_run_end Per run, immediately after on_run_scored hooks finish The run result is final; the next event is another reset or scenario teardown.
on_scenario_end Once per scenario, after all runs ctx.aggregate holds the scenario's AggregateResult.
on_pack_end Once per wt run invocation, after the sweep loop ctx.aggregate holds the completed scenario aggregates.

HookContext is intentionally scoped. It exposes read-only run metadata such as ctx.scenario, ctx.agent, ctx.run_id, ctx.session_id, ctx.trace, ctx.score, and ctx.aggregate, depending on the lifecycle point. Treat those objects as immutable. The framework passes live objects for cost reasons, but mutation is undefined behavior.

The hook capabilities are:

API Where it works Meaning
ctx.converse(text) -> str Only in on_run_scored Sends one diagnostic user turn into the same run session and returns normalized assistant text. The turn is never appended to trace.turns.
ctx.emit_artifact(payload, label=None) Any hook point Buffers JSON-serializable payloads for the CLI to persist. Run artifacts are written beside the trace; scenario and pack artifacts are written at the runs-directory root.
ctx.warn(message) Any hook point Records a non-fatal hook warning through the same hook:<name>: ... channel used for contained hook exceptions.

Hooks never receive filesystem paths. emit_artifact() is buffered because only the CLI knows where the trace and score sidecars are being written.

Failure containment is part of the contract. A broken hook never fails a run. If a hook raises, Wind Tunnel records a hook:<name>: ... warning with the run story and continues. If ctx.converse() times out, the hook sees an error and the artifact can record timed_out: true; the score remains whatever it already was.

Register external hooks with the same entry-point pattern used for runtimes and scenario packs:

[project.entry-points."windtunnel.hooks"]
run_notes = "my_windtunnel_hooks.hooks:RunNotesHook"

The entry point may load a Hook instance or a Hook class; classes are instantiated with no arguments. After changing entry points, reinstall the package before trusting discovery.

Activation is explicit:

uv run wt run --runtime <your-runtime> --hook run_notes

Installing a hook package never changes bench behavior by itself. That rule is not ceremony: gating benches need comparable artifacts across installs and CI images. No --hook flag means no hooks run, even if hook packages are installed.

Continuous state probes

Subclass StateProbeHook when reset_state() should return a fixture to the same external state before every run. The first post-reset snapshot becomes the baseline; later post-reset mismatches report that the previous run may have leaked state past reset.

from pathlib import Path

from windtunnel.hooks.state_probe import StateProbeHook


class TempStateDirHook(StateProbeHook):
    name = "tmp_state"

    def __init__(self, root: Path) -> None:
        super().__init__()
        self.root = root

    def capture_state(self) -> dict:
        return {
            "files": sorted(
                str(path.relative_to(self.root))
                for path in self.root.rglob("*")
                if path.is_file()
            )
        }

A violation on run 2 appears on that run's trace warning channel:

hook:tmp_state: post-reset state differs from the first-run baseline; the previous run may have leaked past reset_state(): changed keys files

It also emits a bounded sidecar, for example <stem>.tmp_state.violation.json:

{
  "schema_version": 1,
  "run_id": "...",
  "baseline_run_id": "...",
  "violation": "post_reset_state_mismatch",
  "difference": {"summary": "changed keys files"}
}

StateProbeHook reuses the same StateProbe.capture() snapshot shape used by run_reset_canary(..., state_probe=...), but it does not replace the canary. The canary remains the wt doctor hard gate; continuous probes report through warnings and artifacts only, never through scores or exit codes.

For the complete contracts, ordering guarantees, artifact naming rules, and the debrief schema, see Design 0003: lifecycle hooks.