A hook wraps what the agent does, so cross-cutting concerns — auth, budgets, quotas, audit, approval policy — live in one place instead of inside every tool. Hooks can observe, or answer in place of the real call.
There are two sides:
hastekit.Hook is both. Implement only the half you care about by embedding the no-op other half — agents.NoopToolCallHook or agents.NoopModelCallHook.
Attach hooks with AgentConfig.Hooks:
Model call hooks
The case this exists for is spending. A run calls the model once per loop iteration and each call costs money, so “may this run afford another call?” has to be asked before the call and the answer recorded after it.
ContinueModelCall() lets the call go to the provider.
HandleModelCall(resp) answers for the model — the provider is never contacted (before) or its reply is replaced (after).
agents.ModelCallText(text) builds that answer: one assistant message, no tool calls, so the loop takes it as the model’s final word and the turn ends there.
- Returning an error from either method fails the run. Reserve it for when there is nothing sensible to say.
What a model hook sees
BeforeModelCall receives the shape of the call, not the prompt. That is what a budget check needs, and it keeps the conversation from crossing a durable boundary twice.
ModelCallResult carries Usage for the one call that just completed.
ContextTokens uses the same reckoning as the summarizer: the last measured prompt plus an estimate of everything appended since. It is the best pre-call estimate of what this call will cost on input.
The tool-call side has the same shape. A policy hook is a few lines:
call is the whole request — the tool’s name and the arguments the model chose (via the embedded responses.FunctionCallMessage), the thread and agent it belongs to, and RunContext. That is the pairing an access check needs: who is asking, and what they are asking for.
ToolCall carries the call, not the tool, so it has no annotations on it. To gate on tool annotations, keep the tools your hook cares about on the hook itself and read agents.AnnotationsOf(tool).IsDeclaredDestructive(), matching by name against call.Name.
ContinueToolCall() passes the call along — to the next hook, then to the tool.
HandleToolCall(resp) says the hook answered; the real call never happens (before) or its result is replaced (after).
agents.ToolCallResult(call, output) builds that answer. Use it rather than hand-building a response: it stamps the call’s ids onto the result, and the loop pairs every result with its function_call by them.
- Returning an error does not fail the run — the error’s text becomes the call’s result. An answer the model can read and work around is almost always more useful than a broken run.
Tool hooks wrap every tool the agent calls: its own function tools, its sub-agent tools, and every MCP server’s. Handoffs do not pass through them — transfer_to_agent calls out to nothing, and the target agent’s own hooks govern what it then does.
AfterToolCall does not run on a paused call. A pause has no result yet; the call comes back through the hooks when the run resumes.
Order of execution
For one call: every Before… in the order the hooks were registered, until one settles the call — then the real call, unless one did — then every After… in the same order.
Notes
Handled is explicit. It’s a flag rather than a nil check on the response, because “I answered, and the answer is nothing to say” differs from “carry on without me”.
- Run context comes along.
call.RunContext is the per-run map you set on AgentInput, so per-tenant data (a JWT, an org id) is available without threading it through every tool.
GetName() must be unique per agent and stable across deploys. Durable runtimes name each hook’s journaled step after it, so a renamed hook is a new step on replay — the same hazard as renaming a Temporal activity.
- Hooks run as their own durable steps. Under Restate or Temporal each hook call is journaled, so a check that talks to a billing service is not re-run on every replay.
Returning an interrupt from a hook
A response carrying Interrupts pauses the run instead of answering it — which is how an unauthenticated caller is sent to a login URL from a hook rather than from inside every tool. See Human in the Loop for the pause/resume flow.
Complete Example