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.

Runs · no key (the model is scripted) Assumes · the quickstart's ledger section Package · @animahealth/adk (MIT)

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:

  • beforeAgent returning a string replaces the run's output. Use a braced body when you only meant to log.
  • afterTurn fires only under app.handler.turn, plus the rest and agui handlers that delegate to it. Never under a bare app.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:

  • throw aborts the run with the original error.
  • skip records the error and carries on.
  • abort ends the invocation cleanly.
  • retry, with an optional delay, runs the same step again.
  • fallback substitutes a result as if the step had succeeded.
  • pass declines, 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, then pass.
  • rateLimitHandler — a retry handler that only fires on rate limit, 429, or too-many-requests messages.
  • timeoutHandler — matches timed out, then fallback if you gave it a fallbackResult, otherwise skip.
  • 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.