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.
Strands separates state into three distinct stores, each with different visibility and lifetime:
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.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.
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().
| Need | Use | Why |
|---|---|---|
| Model needs context from prior turns | agent.messages | It's what the model sees — the only way to give it memory |
| Track total cost across a session | agent.appState | Persists across invocations, model doesn't need to see dollar amounts |
| Pass request-ID to all hooks in one invoke | invocationState | Scoped to one call, shared by reference, no cleanup needed |
| Accumulate results that a hook summarizes at the end | invocationState | Hook reads what tools wrote during the same invocation |
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)
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.
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.
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.
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.
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)
})
Some hook events expose properties you can mutate to alter behavior:
| Event | Mutable Property | Effect |
|---|---|---|
BeforeToolCallEvent | cancel = true | Skips tool execution entirely. Returns a "cancelled" result to the model. |
BeforeToolCallEvent | toolUse | Modify or replace the tool use object (rename tool, change input). The modified version executes. |
AfterToolCallEvent | result | Replace 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]'
}
})
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.
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).
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.
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: [/* ... */],
})
Hooks are how you add operational concerns without modifying agent logic. This matters because:
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.
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.BeforeToolCallEvent hook sets event.cancel = true. What does the model receive?agent.appState and a class property on a Plugin?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.Hooks — Strands Agents Docs — Full reference for all hook events, mutable properties, plugin interface, and composition patterns.