State & Hooks

Lesson 3 · Strands Agents (TypeScript) · ~15 minutes

The agent loop from Lesson 1 controls how the cycle runs. Tools from Lesson 2 define what the agent can do. This lesson covers the two mechanisms that let you control agent behavior from outside the prompt: state management and lifecycle hooks.

State lets you persist information across turns without polluting the model's context window. Hooks let you intercept every phase of the loop — logging, rate-limiting, cancelling dangerous tool calls, injecting context — without touching agent logic. Together, they're how production agents get guardrails, audit trails, and cost controls.

Three Types of State

Strands separates state into three distinct stores, each with different visibility and lifetime:

Conversation History

agent.messages

The full message array the model sees. User turns, assistant turns, tool results. This is the model's memory.

Lifetime: Persists across invocations on the same agent instance. Managed by ConversationManager.

Agent State

agent.appState

A persistent key-value store not sent to the model. JSON-serializable. Use for counters, feature flags, accumulated results, user preferences.

Lifetime: Persists across invocations. You own serialization/deserialization.

Invocation State

invocationState

Per-invoke context object. Shared by reference across all hooks and tools within a single invoke() call. Returned on the result.

Lifetime: One invocation only. Fresh each time you call invoke().

When to use which

NeedUseWhy
Model needs context from prior turnsagent.messagesIt's what the model sees — the only way to give it memory
Track total cost across a sessionagent.appStatePersists across invocations, model doesn't need to see dollar amounts
Pass request-ID to all hooks in one invokeinvocationStateScoped to one call, shared by reference, no cleanup needed
Accumulate results that a hook summarizes at the endinvocationStateHook reads what tools wrote during the same invocation

Example: Using all three

import { Agent, tool } from '@strands-agents/sdk'
import { z } from '@strands-agents/sdk/zod'

const agent = new Agent({
  model: 'us.anthropic.claude-sonnet-4-6-20250725-v1:0',
  tools: [/* ... */],
})

// Agent state — persists across invocations, invisible to model
agent.appState.totalCost = 0
agent.appState.invokeCount = 0

// Invoke with invocation state — scoped to this one call
const result = await agent.invoke('Analyze the Q3 report', {
  invocationState: {
    requestId: crypto.randomUUID(),
    toolCallLog: [],  // hooks will push to this
  },
})

// After invoke, read what accumulated
console.log('Request:', result.invocationState.requestId)
console.log('Tools called:', result.invocationState.toolCallLog)

// Update persistent state
agent.appState.totalCost += result.metrics.totalTokens * 0.00001
agent.appState.invokeCount += 1

// Conversation history is updated automatically
console.log('Messages in history:', agent.messages.length)
Key distinction

appState survives between invocations but you must serialize it yourself if you want durability across process restarts. invocationState is fire-and-forget — it exists only for the duration of one invoke() call. Both are invisible to the model.

Conversation Management

Without management, agent.messages grows unboundedly. After 50 turns of tool-heavy interaction, you'll hit context limits. Strands provides SlidingWindowConversationManager to keep history within bounds:

import { Agent, SlidingWindowConversationManager } from '@strands-agents/sdk'

const agent = new Agent({
  model: 'us.anthropic.claude-sonnet-4-6-20250725-v1:0',
  conversationManager: new SlidingWindowConversationManager({
    windowSize: 20,  // Keep last 20 messages
  }),
  tools: [/* ... */],
})

The sliding window trims oldest messages when the array exceeds windowSize. This is a blunt instrument — it doesn't summarize, it discards. For most single-task agents with turn limits, this is fine. The model forgets early turns but keeps recent context.

Watch for orphaned tool results

If the window trims an assistant message containing a tool_use block but keeps the corresponding tool_result, the model sees a result with no matching call. Strands handles this by trimming in message pairs — but be aware if you manipulate agent.messages manually.

The Hooks System

Hooks intercept the agent loop at seven well-defined points. They're your primary mechanism for cross-cutting concerns: logging, cost tracking, guardrails, audit, rate limiting.

Hook events and when they fire

BeforeInvocationEvent → invoke() called, before first model call ┌─ loop starts ───────────────────────── │ BeforeModelCallEvent │ → about to send messages to model │ ... model responds ... │ AfterModelCallEvent │ → model response received │ MessageAddedEvent │ → each time a message is appended to history │ ... if tool_use in response ... │ BeforeToolCallEvent │ → about to execute tool (can cancel/modify) │ ... tool runs ... │ AfterToolCallEvent │ → tool finished (can modify result) └─ loop continues or exits ──────────── AfterInvocationEvent → loop finished, about to return AgentResult

Registering hooks

Use agent.addHook(EventClass, callback) to register a hook for any event:

import { Agent, BeforeToolCallEvent, AfterToolCallEvent } from '@strands-agents/sdk'

const agent = new Agent({
  model: 'us.anthropic.claude-sonnet-4-6-20250725-v1:0',
  tools: [/* ... */],
})

// Log every tool call
agent.addHook(BeforeToolCallEvent, (event) => {
  console.log(`[hook] Calling tool: ${event.toolUse.name}`)
  console.log(`[hook] Input:`, JSON.stringify(event.toolUse.input))
})

agent.addHook(AfterToolCallEvent, (event) => {
  console.log(`[hook] Tool result:`, event.result)
})

Mutable event properties

Some hook events expose properties you can mutate to alter behavior:

EventMutable PropertyEffect
BeforeToolCallEventcancel = trueSkips tool execution entirely. Returns a "cancelled" result to the model.
BeforeToolCallEventtoolUseModify or replace the tool use object (rename tool, change input). The modified version executes.
AfterToolCallEventresultReplace the tool result before it's sent back to the model. Use for sanitization or enrichment.
// Block a dangerous tool
agent.addHook(BeforeToolCallEvent, (event) => {
  if (event.toolUse.name === 'delete_database') {
    event.cancel = true  // Tool won't execute
    console.warn('[guardrail] Blocked delete_database call')
  }
})

// Sanitize tool output before model sees it
agent.addHook(AfterToolCallEvent, (event) => {
  if (typeof event.result === 'string' && event.result.includes('SECRET')) {
    event.result = '[REDACTED — contains sensitive data]'
  }
})
Cancellation from hooks

When you set event.cancel = true on a BeforeToolCallEvent, the model receives a tool result indicating cancellation. It can then decide what to do — typically it'll inform the user or try a different approach. This is far better than throwing an error, which would require the model to recover from a failure.

Plugins: Bundled Hook Collections

When you have multiple related hooks (logging + timing + error tracking), bundle them as a Plugin. A plugin is a class with an initAgent() method that registers all its hooks:

import {
  Agent,
  Plugin,
  BeforeToolCallEvent,
  AfterToolCallEvent,
  BeforeInvocationEvent,
  AfterInvocationEvent,
} from '@strands-agents/sdk'

class LoggingPlugin implements Plugin {
  private timers = new Map<string, number>()

  initAgent(agent: Agent): void {
    agent.addHook(BeforeInvocationEvent, (event) => {
      console.log(`[LoggingPlugin] Invocation started`)
      this.timers.set('invocation', Date.now())
    })

    agent.addHook(BeforeToolCallEvent, (event) => {
      const callId = event.toolUse.id
      this.timers.set(callId, Date.now())
      console.log(`[LoggingPlugin] Tool: ${event.toolUse.name}`, event.toolUse.input)
    })

    agent.addHook(AfterToolCallEvent, (event) => {
      const callId = event.toolUse.id
      const start = this.timers.get(callId)
      const elapsed = start ? Date.now() - start : 0
      this.timers.delete(callId)
      console.log(`[LoggingPlugin] Tool completed in ${elapsed}ms`)
    })

    agent.addHook(AfterInvocationEvent, (event) => {
      const start = this.timers.get('invocation')
      const elapsed = start ? Date.now() - start : 0
      this.timers.delete('invocation')
      console.log(`[LoggingPlugin] Invocation finished in ${elapsed}ms`)
      console.log(`[LoggingPlugin] Stop reason: ${event.result.stopReason}`)
    })
  }
}

// Register the plugin
const agent = new Agent({
  model: 'us.anthropic.claude-sonnet-4-6-20250725-v1:0',
  plugins: [new LoggingPlugin()],
  tools: [/* ... */],
})

Plugins are the composition unit. They're instantiated once, and initAgent() is called when the agent is created. This keeps hook registration co-located with the state the hooks need (like the timers map above).

Practical Example: Tool-Count Limiter

A common production need: cap how many tools an agent can call per invocation. This prevents runaway loops where the model calls the same tool repeatedly (e.g., retrying a broken API).

import {
  Agent,
  Plugin,
  BeforeToolCallEvent,
  AfterInvocationEvent,
} from '@strands-agents/sdk'

class ToolCountLimiterPlugin implements Plugin {
  private maxCalls: number
  private callCount = 0

  constructor(maxCalls: number) {
    this.maxCalls = maxCalls
  }

  initAgent(agent: Agent): void {
    agent.addHook(BeforeToolCallEvent, (event) => {
      this.callCount++
      if (this.callCount > this.maxCalls) {
        event.cancel = true
        console.warn(
          `[ToolLimiter] Blocked ${event.toolUse.name} — ` +
          `exceeded ${this.maxCalls} tool calls`
        )
      }
    })

    // Reset counter between invocations
    agent.addHook(AfterInvocationEvent, () => {
      this.callCount = 0
    })
  }
}

const agent = new Agent({
  model: 'us.anthropic.claude-sonnet-4-6-20250725-v1:0',
  plugins: [
    new LoggingPlugin(),
    new ToolCountLimiterPlugin(10),  // Max 10 tool calls per invoke
  ],
  tools: [/* ... */],
})

Note the separation of concerns: the agent logic doesn't know about the limiter. The limiter doesn't know about logging. Both compose cleanly via the plugin system.

Combining State and Hooks

The real power emerges when hooks read and write state. Here's a cost-tracking plugin that uses appState for persistence and invocationState for per-request accounting:

class CostTrackingPlugin implements Plugin {
  private costPerInputToken: number
  private costPerOutputToken: number
  private budgetLimit: number

  constructor(opts: { costPerInputToken: number; costPerOutputToken: number; budgetLimit: number }) {
    this.costPerInputToken = opts.costPerInputToken
    this.costPerOutputToken = opts.costPerOutputToken
    this.budgetLimit = opts.budgetLimit
  }

  initAgent(agent: Agent): void {
    // Initialize persistent state
    agent.appState.totalSpend ??= 0

    agent.addHook(BeforeInvocationEvent, (event) => {
      // Check budget before starting
      if (agent.appState.totalSpend >= this.budgetLimit) {
        throw new Error(
          `Budget exhausted: $${agent.appState.totalSpend.toFixed(4)} ` +
          `of $${this.budgetLimit} spent`
        )
      }
    })

    agent.addHook(AfterModelCallEvent, (event) => {
      // Track cost per model call
      const inputCost = (event.usage?.inputTokens ?? 0) * this.costPerInputToken
      const outputCost = (event.usage?.outputTokens ?? 0) * this.costPerOutputToken
      const callCost = inputCost + outputCost

      agent.appState.totalSpend += callCost

      // Also record in invocation state for per-request reporting
      event.invocationState.costThisInvoke ??= 0
      event.invocationState.costThisInvoke += callCost
    })

    agent.addHook(AfterInvocationEvent, (event) => {
      console.log(
        `[Cost] This invoke: $${event.invocationState.costThisInvoke?.toFixed(4)} | ` +
        `Total: $${agent.appState.totalSpend.toFixed(4)} / $${this.budgetLimit}`
      )
    })
  }
}

const agent = new Agent({
  model: 'us.anthropic.claude-sonnet-4-6-20250725-v1:0',
  plugins: [
    new CostTrackingPlugin({
      costPerInputToken: 0.000003,    // $3 per 1M input tokens
      costPerOutputToken: 0.000015,   // $15 per 1M output tokens
      budgetLimit: 1.00,              // Hard stop at $1
    }),
  ],
  tools: [/* ... */],
})

The Production Insight

Hooks are how you add operational concerns without modifying agent logic. This matters because:

Design principle

If a constraint must always hold (budget limits, blocked tools, PII redaction), enforce it in a hook. If a constraint is a preference (response style, level of detail), put it in the prompt. Hooks are for invariants; prompts are for heuristics.

You need to track which user triggered an invocation across all hooks and tools in that call. Which state mechanism do you use?
Correct. invocationState is scoped to one invoke call and shared by reference across all hooks and tools in that invocation — exactly right for per-request context like user IDs, request IDs, or trace spans.
invocationState is the answer. It's per-invoke, shared by reference across hooks and tools, and returned on the result. appState would persist between invocations (wrong lifetime). Messages would waste context window. Globals are unsafe with concurrent invocations.
A BeforeToolCallEvent hook sets event.cancel = true. What does the model receive?
Correct. The model receives a tool result saying the call was cancelled. This keeps the message history valid (every tool_use has a matching tool_result) and lets the model decide how to proceed — inform the user, try another approach, or stop.
When a hook cancels a tool, the model still gets a tool_result message (indicating cancellation). This maintains history consistency and lets the model react. The invocation doesn't stop — the model sees the result and decides what to do next.
What's the difference between agent.appState and a class property on a Plugin?
Correct. appState is the shared, JSON-serializable store accessible from anywhere (tools, hooks, calling code). Plugin class properties are encapsulated — only that plugin's hooks can read/write them. Use appState for data that crosses boundaries; plugin properties for internal plugin state like timers.
appState is a shared key-value store (JSON-serializable, accessible from tools and hooks and calling code). Plugin class properties are private to that plugin instance — only its own hooks use them. Neither is sent to the model, and neither persists across process restarts without explicit serialization.
📖 Primary Source

Hooks — Strands Agents Docs — Full reference for all hook events, mutable properties, plugin interface, and composition patterns.

💬 Questions? Ask about any hook event, state management pattern, or how to build a specific guardrail. I can walk through PII redaction plugins, retry-limit hooks, or conversation persistence strategies.
← Back Next →