Agent Development Kit · Prove & ship
Hooks and guardrails
Hooks let your code observe and control an agent's execution. Bypass a model call, choose a tool call in code, check an action before it runs, or replace a result. Error handlers control retries and recovery. These examples run without an API key.
Step 1
One hook, nine lifecycle points
A Hook is a plain object with optional methods: onEvent and
onStep observe, beforeAgent/afterAgent,
beforeModel/afterModel and beforeTool/afterTool
wrap each phase, and afterTurn runs inside the commit boundary. It is one
interface rather than a split observer and interceptor because the concerns that need both —
rate limits, budget caps, redaction — need them in the same place. Implement the methods you
want; the rest are undefined and cost nothing.
The setup uses a MockAdapter to script provider responses and record model calls.
Tools and hooks execute in the real ADK runtime. See Testing for the
test utilities.
import { adk } from '@animahealth/adk'
import { openai } from '@animahealth/adk/openai'
import { MockAdapter } from '@animahealth/adk/testing'
import { z } from 'zod'
// Each turn the adapter serves the next scripted response; every cell below re-scripts it, so
// cells stay deterministic however often you press Run.
const scripted = new MockAdapter({ responses: [] })
const app = adk({ name: 'guardrails', adapters: { openai: scripted } })
// The tool records what it actually did — that is how we prove a guardrail stopped it.
const issued: number[] = []
const refund = app.tool({
name: 'refund',
description: 'Refund a payment, in pence',
schema: z.object({ amount: z.number() }),
execute: (ctx) => {
issued.push(ctx.args.amount)
return { refunded: ctx.args.amount }
},
})
const support = app.agent({
name: 'support',
model: openai('gpt-5.6-luna'),
context: [app.context.system('Refund what the customer asks for.'), app.context.history()],
tools: [refund],
})
support.name
Now a hook that only watches. onEvent sees the stream as it happens — the same
events that land in run.session.events — while the phase methods bracket the
work. In the output, beforeTool fires before the tool runs, and
afterTool receives the finished tool_result event, result payload
and all.
const seen: string[] = []
scripted.setResponses([
{ toolCalls: [{ name: 'refund', args: { amount: 4000 } }] },
{ text: 'Refunded 4000. Anything else?' },
])
const traced = await app.run(support, {
input: 'Refund my last order.',
hooks: [
{
name: 'trace',
onEvent: (event) => {
seen.push(`event ${event.type}`)
},
beforeAgent: (ctx) => {
seen.push(`beforeAgent ${ctx.runnable.name}`)
},
beforeTool: (ctx, call) => {
seen.push(`beforeTool ${call.name}`)
},
afterTool: (ctx, result) => {
seen.push(`afterTool ${result.name} -> ${JSON.stringify(result.result)}`)
},
afterAgent: (ctx, output) => {
seen.push(`afterAgent ${typeof output}`)
},
},
],
})
seen
Every method here returned undefined, which is what makes it an observer. The
moment one returns a value it becomes an interceptor — section 3. Two warnings the types will
not give you:
-
beforeAgentreturning a string replaces the run's output. Use a braced body when you only meant to log. -
afterTurnfires only underapp.handler.turn, plus therestandaguihandlers that delegate to it. Never under a bareapp.run.
Step 2
Composition order
Hooks attach at three places: adk({ hooks }) for the app,
app.agent({ hooks }) for one agent, and app.run(…, { hooks }) for
one call. The runner flattens all three into a single hook — app outermost, then agent, then
call site — and the flattening rule is the interesting part. Before hooks run
outer to inner and the first non-undefined return wins, so an outer layer can
pre-empt an inner one. After hooks run inner to outer, each free to rewrite
what the previous returned, so an outer layer gets the last word on the result. Observation is
different again: onEvent and onStep fan out to every hook, and their
errors are swallowed — watching must never abort a run, while intercepting is control flow and
throws.
composeHooks is that flattening, exported so you can build a bundle yourself and
pass it anywhere a single hook goes. The built-ins are ordinary hooks too:
loggingHook logs agent and tool boundaries, metricsHook turns events
into counter callbacks, and consoleHook renders a streaming run to stdout. That
last one writes ANSI to process.stdout, so it belongs in a terminal, not this
page. Each is also reachable as app.hook.logging(),
app.hook.metrics() and app.hook.console().
import { composeHooks, metricsHook } from '@animahealth/adk'
import type { Hook } from '@animahealth/adk'
const unwound: string[] = []
const measured: string[] = []
const stamp = (label: string): Hook => ({
name: label,
beforeTool: () => {
unwound.push(`before ${label}`)
},
afterTool: () => {
unwound.push(`after ${label}`)
},
})
// One hook out of three: a built-in and two hand-written layers.
const bundle = composeHooks([
metricsHook({
onToolResult: (name, durationMs, error) => {
measured.push(`${name} ${error ? 'failed' : 'ok'}`)
},
}),
stamp('outer'),
stamp('inner'),
])
scripted.setResponses([
{ toolCalls: [{ name: 'refund', args: { amount: 250 } }] },
{ text: 'Refunded 250.' },
])
await app.run(support, { input: 'Refund the delivery fee.', hooks: [bundle] })
const composition = { unwound, measured }
composition
Read unwound: before goes outer then inner, after comes back inner then outer.
That is why a redaction hook belongs outside a logging hook — the logger sees whatever the
redactor already rewrote.
Deterministic tool calls
Run a tool without asking the model to choose it
Return a ModelStepResult from beforeModel to bypass that provider
call. Include a tool call in both stepEvents and toolCalls, with
terminal: false. The runtime records the call and runs it through the normal tool
pipeline, including argument validation and tool hooks.
This example chooses a fixed refund in code. The refund tool above only records an amount in an array. On the next iteration, the hook sees the tool result and ends the run.
import type { Hook } from '@animahealth/adk'
import type { ToolCallEvent } from '@animahealth/adk'
issued.length = 0
scripted.reset()
const fixedRefund: Hook = {
name: 'fixed_refund',
beforeModel: (ctx, renderCtx) => {
const refunded = ctx.session.events.some((event) =>
event.type === 'tool_result' &&
event.invocationId === ctx.invocationId &&
event.name === 'refund',
)
if (refunded) {
return { stepEvents: [], toolCalls: [], terminal: true }
}
const call: ToolCallEvent = {
id: crypto.randomUUID(),
callId: crypto.randomUUID(),
type: 'tool_call',
createdAt: Date.now(),
invocationId: ctx.invocationId,
agentName: renderCtx.agentName,
name: 'refund',
args: { amount: 250 },
}
return { stepEvents: [call], toolCalls: [call], terminal: false }
},
}
const forced = await app.run(support, {
input: 'Apply the fixed delivery refund.',
hooks: [fixedRefund],
})
const bypassResult = {
status: forced.status,
modelCalls: scripted.stepCalls.length,
refunds: [...issued],
toolResults: forced.session.events.filter((event) => event.type === 'tool_result'),
}
bypassResult
The result shows modelCalls: 0 and refunds: [250], with a recorded
tool result. The hook chose the call; the tool still executed. To let the model handle other
cases or explain a tool result, return undefined instead. The next model call
proceeds normally.
This control sits inside an agent's loop. Use a
step when the decision belongs between workflow
stages. Use a beforeModel hook when you want to replace a particular model
decision while keeping the agent's tools and context.
Step 3
A hook that says no
Interception is the return value. beforeTool returning a
ToolResultEvent means this is the result: the tool never executes, the
event is appended to the session, and the model reads it on the next turn and explains itself.
It fires before the arguments are parsed and before the tool is even resolved, so the hook
sees what the model asked for — the right place for a cap, an allow-list, or a permission
check.
The symmetric returns elsewhere: beforeModel returning a
ModelStepResult skips the provider call, afterModel and
afterTool returning a value replace the result, and
beforeModel/afterModel may also return a Runnable to
hand the invocation to a different agent. beforeTool and
afterTool cannot transfer — a tool-level handoff has no coherent meaning.
issued.length = 0 // rerunnable: forget what the earlier cells refunded
const refundCap: Hook = {
name: 'refund_cap',
beforeTool: (ctx, call) => {
const amount = Number(call.args.amount)
if (call.name === 'refund' && amount > 5000) {
return {
id: call.id,
type: 'tool_result',
createdAt: Date.now(),
invocationId: call.invocationId,
agentName: call.agentName,
callId: call.callId,
name: call.name,
error: `Refunds over 5000 need a human. The model asked for ${amount}.`,
}
}
},
}
scripted.setResponses([
{ toolCalls: [{ name: 'refund', args: { amount: 12000 } }] },
{ text: 'That refund is over my limit — a manager has to approve it.' },
])
const capped = await app.run(support, { input: 'Refund my £120 order.', hooks: [refundCap] })
const cappedReport = { executed: issued, text: capped.output.text }
cappedReport
executed is empty: the model asked, the hook answered, your money stayed put. One
asymmetry: a vetoed call skips afterTool entirely, because there was no tool
result to post-process. Put audit logging in beforeTool or
onEvent if it must see the calls that were refused.
Step 4
What a throw actually does
A tool that throws does not fail the run. Every failure becomes an
ErrorContext — the invocation, the phase (model,
tool, callback or render), the attempt number, the
error — and is offered to the error handlers. With no handler registered the default is
asymmetric. A tool error is skipped, recorded as the error field
of its tool_result so the model can see it and react.
A model error is thrown, because there is nothing left to reason with.
import { isToolResultEvent } from '@animahealth/adk'
const lookupOrder = app.tool({
name: 'lookup_order',
description: 'Look up an order by id',
schema: z.object({ id: z.string() }),
timeout: 50,
execute: async (ctx) => {
if (ctx.args.id === 'A-42') throw new Error('order service unreachable')
await new Promise((resolve) => setTimeout(resolve, 2000)) // slower than the 50ms timeout
return { id: ctx.args.id, status: 'shipped' }
},
})
const orderDesk = app.agent({
name: 'order_desk',
model: openai('gpt-5.6-luna'),
context: [app.context.system('Look the order up before answering.'), app.context.history()],
tools: [lookupOrder],
})
scripted.setResponses([
{ toolCalls: [{ name: 'lookup_order', args: { id: 'A-42' } }] },
{ text: 'The order service is down, so I could not check A-42.' },
])
const survived = await app.run(orderDesk, 'Where is order A-42?')
const survivedReport = {
status: survived.status,
toolResults: survived.session.events
.filter(isToolResultEvent)
.map((event) => ({ name: event.name, error: event.error })),
text: survived.output.text,
}
survivedReport
The run is completed, not error. That is the shape you want for a
flaky dependency and the shape you must override for a dependency whose failure is not
survivable — the subject of section 5.
Step 5
Six recovery actions
An error handler is { canHandle?, handle }, and handle returns one
of six verdicts:
throwaborts the run with the original error.skiprecords the error and carries on.abortends the invocation cleanly.retry, with an optionaldelay, runs the same step again.fallbacksubstitutes a result as if the step had succeeded.passdeclines, handing the decision to the next handler.
Handlers are consulted in order — runner, then agent, then call site. The first verdict that
is not pass wins. If every handler passes, the phase default from section 4
applies.
Five handlers ship:
retryHandler— bounded exponential backoff, thenpass.-
rateLimitHandler— a retry handler that only fires on rate limit, 429, or too-many-requests messages. -
timeoutHandler— matchestimed out, thenfallbackif you gave it afallbackResult, otherwiseskip. loggingHandler— logs and always passes.defaultHandler— the phase default, made explicit.
The cell below stacks three of them against a run that fails twice, in two different phases.
import { loggingHandler, retryHandler, timeoutHandler } from '@animahealth/adk'
const failures: string[] = []
scripted.setResponses([
{ error: new Error('503 upstream unavailable') }, // model call fails
{ toolCalls: [{ name: 'lookup_order', args: { id: 'B-7' } }] }, // retry: this one calls the slow tool
{ text: 'B-7 is unconfirmed — the live lookup timed out, so this is the cached status.' },
])
const recovered = await app.run(orderDesk, {
input: 'Where is order B-7?',
errorHandlers: [
// Observes every failure and defers — `pass` keeps the chain moving.
loggingHandler({
onError: (ctx) => {
failures.push(`${ctx.phase} · attempt ${ctx.attempt} · ${ctx.error.message}`)
},
}),
// Ordered before the retry handler: a timeout should degrade, not hammer the dependency.
timeoutHandler({ fallbackResult: { status: 'unknown', source: 'cache' } }),
// `retryable` narrows this one to model failures; without it, it would retry everything.
retryHandler({ maxAttempts: 3, baseDelay: 0, retryable: (ctx) => ctx.phase === 'model' }),
],
})
const recoveredReport = {
failures,
toolResults: recovered.session.events
.filter(isToolResultEvent)
.map((event) => ({ name: event.name, result: event.result })),
text: recovered.output.text,
}
recoveredReport
Two failures, two different verdicts, one completed run. The model's 503 was retried and the
second attempt called the tool; the tool's timeout was substituted with a cached-looking
result that the model then wrote its answer from. The ordering is the design here: had
retryHandler come first without its retryable predicate, it would
have claimed the timeout too and retried a dependency that was already too slow.
Step 6
Timeouts and caps
Deadlines exist at three scopes, and they do different things. A tool's own
timeout (milliseconds) races that one execution and raises
Tool 'name' timed out after Nms — an ordinary tool-phase error, which is why
timeoutHandler can catch it and why the tool_result is flagged
timedOut. A tool's retry takes a RetryConfig — spelled
out on the tools page — and retries inside the tool, before any error
handler is consulted. And app.run(…, { timeout }) bounds the whole run: it races
the event stream and rejects with Timeout after Nms, so it is a hard stop, not a
recoverable phase error.
Two caps stop a loop that never settles. An agent's maxSteps (default 25) bounds
reasoning iterations in one invocation and ends the run with status
max_steps rather than an error; maxTurns (default 100) bounds
yield-and-resume cycles the same way. A third cap is not configurable: an error handler that
keeps answering retry for the same tool call is cut off after ten attempts, so a
handler bug degrades into a recorded failure instead of an infinite loop.
Every guardrail decision ends as an event in the session, so it is already in the audit trail you would read anyway.
Which raises the question of where that session lives once the process ends. Next: stores and the sleeping agent.