Lifecycle and hook points¶
HarnessWeaver's most important contract is a fixed set of 13 hook points. Every runner and adapter maps its own events onto them, so one guardrail or policy works unchanged across raw SDK calls, Pydantic AI, the Vercel AI SDK or anything else.
Each hook point has a direction:
- Inbound hooks fire on the way into an operation, and their middleware runs outermost-first (baseline first).
- Outbound hooks fire on the way out, and their middleware runs innermost-first (baseline last).
The hook bus explains the ordering.
The 13 hook points¶
| Hook point | Direction | Fires when | Middleware may | Typical modules |
|---|---|---|---|---|
run.start |
inbound | The envelope is built, before any work | Reject, check idempotency, set budget and deadline | policy (run.start rules), budgets, observability |
input.before |
inbound | Raw input arrives | Validate, redact, block | guardrails (input) |
route.decide |
inbound | A choice is needed: intent, task variant, model alias | Return a Decision |
routing |
context.after |
outbound | Retrieval returns items | Filter, label, drop, cap | context, guardrails (context) |
model.before |
inbound | A model request is about to be sent | Modify messages, pre-check the budget, block | budgets, middleware |
model.after |
outbound | A model response arrives | Record usage and cost, redact, block, retry | budgets, observability |
stream.chunk |
inbound | A streamed chunk arrives | Redact, cut the stream | guardrails (stream) |
tool.before |
inbound | The model (or the app) proposes a tool call | Allow, deny, rewrite arguments, require approval | tools, policy, approvals |
tool.after |
outbound | A tool returns | Sanitize, truncate, label untrusted | tools, guardrails (tool_result) |
output.before |
inbound | A candidate final output exists | Validate, repair, escalate, block | validation, guardrails (output) |
approval.resolved |
inbound | A human answers a suspended step | Re-check edited arguments, resume or end | approvals |
run.error |
outbound | An exception escapes a step | Classify it into the error taxonomy | core |
run.end |
outbound | The run reaches a terminal state | Persist, emit completion, sample for evals | state, evals, observability |
harnessweaver hooks harness.yaml --task <task> prints the middleware on each hook point of your spec, in the order it runs.
A run, end to end¶
sequenceDiagram
autonumber
participant App
participant H as Harness
participant Bus as Hook bus
participant R as Runner
participant M as ModelProvider
participant T as Tools
participant S as StateStore
App->>H: run("billing_agent", input, principal)
H->>Bus: run.start - envelope, budget, deadline
H->>Bus: input.before - redact, validate, block
H->>Bus: route.decide - task variant, model alias
H->>R: execute(task, input)
R->>Bus: context.after - ACL filter, provenance labels
loop bounded steps
R->>Bus: model.before - budget pre-check, guards
R->>M: generate(request)
M-->>R: response + usage
R->>Bus: model.after - cost, redaction
opt streaming
R->>Bus: stream.chunk - moderate, cut
end
opt the model proposes a tool call
R->>Bus: tool.before - then the 9-step governor
alt approval required
Bus-->>H: Suspend
H->>S: checkpoint
H-->>App: RunResult(status=awaiting_approval)
App->>H: approve(run_id, approval_id)
H->>Bus: approval.resolved - re-check edited args
H->>R: resume from checkpoint
end
R->>T: execute (sandbox per manifest)
R->>Bus: tool.after - truncate, redact, label untrusted
end
end
R->>Bus: output.before - validate, repair, guards
H->>Bus: run.end - persist, sample evals
H-->>App: RunResult(status=completed)
Note over H,Bus: Any exception fires run.error (classify, retry or fail)
Not every run fires every hook: a run without retrieval never fires context.after, and run.error fires only on an error. A session fires the hooks your code asks for, in the order it asks.
Event payloads¶
Each hook point has one typed event. Middleware receives it with the run's envelope. The payloads are contracts (schema hook-event), so they hold only HarnessWeaver types, never an SDK's (RULES R7).
| Hook point | Event | Fields |
|---|---|---|
run.start |
RunStartEvent |
task, input |
input.before |
InputEvent |
input |
route.decide |
RouteEvent |
request (a DecisionRequest: router, text, options, state), decision |
context.after |
ContextEvent |
source, query, items (ContextItems) |
model.before |
ModelRequestEvent |
request (a ModelRequest) |
model.after |
ModelResponseEvent |
request, response (a ModelResponse) |
stream.chunk |
StreamChunkEvent |
request, chunk (index, delta, usage), text_so_far (the text before this chunk) |
tool.before |
ToolCallEvent |
call (id, name, arguments), tool (a ToolInfo), approval (set when a human already approved this call) |
tool.after |
ToolResultEvent |
call, tool, result (output, is_error, untrusted, truncated) |
output.before |
OutputEvent |
output, attempt (0, then 1 for the first repair), schema_name, approval |
approval.resolved |
ApprovalResolvedEvent |
request (an ApprovalRequest), decision (an ApprovalDecision) |
run.error |
RunErrorEvent |
error (an ErrorInfo), step |
run.end |
RunEndEvent |
status, output, error, usage, cost_usd |
The neutral model contracts are:
| Contract | Fields |
|---|---|
ModelRequest |
alias, model, messages, tools (ToolSpecs), output_schema, temperature, max_tokens, stream, metadata |
Message |
role (system, user, assistant, tool), content, tool_calls, tool_call_id, name, extensions |
ModelResponse |
model, text, tool_calls, output, usage (input_tokens, output_tokens), cost_usd, finish_reason, extensions |
extensions carries a provider's own per-turn data (such as Claude's thinking blocks) that the harness keeps opaque and replays unchanged.
Run statuses¶
A run moves through these statuses; the terminal ones end it.
stateDiagram-v2
[*] --> pending
pending --> running: run.start ok
pending --> blocked: run.start blocks
running --> awaiting_approval: Suspend (checkpoint saved)
awaiting_approval --> running: approved (approval.resolved)
awaiting_approval --> blocked: rejected
awaiting_approval --> escalated: timeout, on_timeout=escalate
running --> escalated: Escalate
running --> blocked: Block
running --> failed: unrecoverable run.error
running --> cancelled: cancel()
awaiting_approval --> cancelled: cancel()
running --> completed: run.end
completed --> [*]
blocked --> [*]
escalated --> [*]
failed --> [*]
cancelled --> [*]
| Status | Meaning |
|---|---|
pending |
Created, not yet started |
running |
Working |
awaiting_approval |
Suspended until a human answers; the checkpoint is saved |
completed |
Finished with an output |
blocked |
A control refused a step; error.details.code says which (policy_denied, budget_exceeded, ...) |
escalated |
Handed to a human queue |
failed |
An error that isn't a control's decision ended it |
cancelled |
cancel() stopped it |
Every run ends with a RunResult: run_id, task, task_version, status, output, error (an ErrorInfo), approval (the pending ApprovalRequest, when waiting), usage, cost_usd, cost_known, trace, started_at and ended_at.
The trace¶
Every run keeps a RunTrace, a framework-neutral record of what happened, on its RunResult. Each TraceEvent has a sequence number, a time, a kind and a source:
| Kind | Recorded |
|---|---|
hook |
A hook point fired, with the combined outcome of its middleware |
outcome |
One middleware's answer, with its band and whether it modified the payload |
failure |
A middleware raised or timed out (its exception type, never its message) |
note |
A framework decision: a guard fired, a budget check, a route chosen, a governor step, a repair |
Notes carry a code (guard_fired, budget_exceeded, repair, ...) and details with ids, names and numbers, never content (RULES R9, R13). harnessweaver.testing.render_trace(result) prints a trace one event per line, and assert_trace checks it in tests (Testing).