Agent Development Kit · Prove & ship
Guardrails and recovery
A hook is one object that both watches a run and can interrupt it. An error handler decides what a failure means. Together they are the layer where you cap a refund, redact an argument, retry a flaky provider, and keep a run alive when a dependency is not. This chapter runs all of it against a scripted model, so every cell works with no 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.
First the setup. runTest from the test kit scripts the model but exposes no hooks
option, so build the app yourself and hand it a MockAdapter — see
testing agents without a model.
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. Note the ordering 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 cliHook 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.cli().
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.
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 exactly 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 deliberately 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 to know — 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
deliberate and 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 — which is what section 5 is for.
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. Order is the whole 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.