Using Strands Hooks to Deterministically Control Your Agent
This article demonstrates how to use Strands Agents lifecycle hooks to enforce deterministic rules that system prompts alone cannot guarantee. Covers four production patterns — authorization-failure sanitization, budget caps, fail-fast, and operation-level query limits — with real CloudWatch traces showing before/after behavior on a data-querying agent built with Amazon Bedrock AgentCore and MCP tools.
Using Strands Hooks to Deterministically Control Agent Behavior and Performance
Introduction
A system prompt is the usual way to shape an LLM's behavior — but it is a request, not a guarantee. A model can ignore an instruction, paraphrase a rule away, or be argued out of a constraint by adversarial input. For rules that must hold every time — budget caps, fail-fast on errors, sanitized error messages, audit logging — you need a control surface outside the LLM's decision-making.
This article introduces Strands hooks. The framework emits typed hook events at specific points in the agent lifecycle: before and after each tool call, before and after each model call, at the start and end of an invocation. You register hook callbacks against the event types you care about, and Strands invokes them deterministically every time those events fire. Each callback receives a strongly-typed event object carrying the data relevant to that stage — the tool name and arguments before it runs, the tool result after. Your Python code sees exactly what the framework sees, before the model does. The model cannot opt out.
We'll also cover how to verify that hooks are working using AWS OpenTelemetry and CloudWatch observability tools — because enforcement without verification is just hope.
The Problem: Two Failure Modes You Can't Prompt Away
To demonstrate these failure modes, we'll use a Retail Data Explorer Agent built for an AWS workshop — a Strands agent that answers plain-English questions about a retail company's operational data. It connects to an AWS Data Processing MCP Server that exposes three tools: manage_aws_glue_databases for catalog discovery, manage_aws_glue_tables for schema exploration, and manage_aws_athena_query_executions for running SQL queries. The agent discovers relevant tables, plans SQL, executes queries via Athena, and presents results — all through natural language.
This architecture makes the failure modes particularly damaging:
- Data agents serve objective facts. When a store manager asks "how many units of Electronics did we sell last quarter?" they expect a number from the database — not a fabrication the model invented because a permission error blocked the query. Trust in the agent's output is binary: if it fabricates once, the entire agent is compromised.
- Data operations are compute-heavy. Each
start-query-executioncall triggers an Athena query that scans data in S3, consumes compute, and adds to token usage as results flow back through the model. Unlike a chatbot that just generates text, every unnecessary tool call here has a direct infrastructure cost.
Failure Mode 1: The Model Interprets What It Shouldn't
When the Data Explorer Agent's tool call hits an authorization error (for example, an under-scoped IAM role), the error message is returned to the model as a tool result and the model improvises around it. Sometimes that means leaking IAM action names and ARNs. Sometimes it means offering to remediate the failure on the user's behalf. Sometimes it means fabricating an answer that was never computed. Your system prompt says "do not leak" and "do not fabricate" — a sufficiently confident model works around both.
Figure 1: Without hooks — the raw
AccessDeniedException on athena:StartQueryExecution is exposed verbatim inside the tool span, and there are no events in the log panel because no hook intercepted it. The model receives this untouched.
# Tool span error content, retrieved from Transaction Search
# (trace 6a4e3928...5ed6):
Error in manage_aws_athena_queries: An error occurred (AccessDeniedException)
when calling the StartQueryExecution operation: You are not authorized to
perform: athena:StartQueryExecution on the resource. After your AWS
administrator or you have updated your permissions, please try again.
Figure 2: The model's response is worse than a passive leak. It names the failing IAM actions (
athena:StartQueryExecution, athena:GetWorkGroup), enumerates the exact permissions the role is missing, and offers to fix the role itself — behaviour a store manager should never see and an under-scoped agent should never propose.
Failure Mode 2: The Model Loops When It Should Stop
Ask the Data Explorer Agent an open-ended question like "What's interesting in our retail data?" and watch what happens without limits. The agent discovers databases, lists tables, describes schemas, runs a query that fails, re-discovers the same schemas it already explored, retries with different syntax, and eventually answers — after triggering far more Athena queries than the question required. Each of those queries scans data in S3 and burns compute regardless of whether the results are ultimately used in the answer.
Figure 3: A representative unguarded run — 9 Athena tool calls, 2 Glue table calls, and 1 Glue database call across 6 event loop cycles. Several of these calls are redundant discovery retries after early query attempts fail.
# Tool call breakdown from CloudWatch trace (no hooks, 12 total calls):
manage_aws_glue_databases ×1 ▉
manage_aws_glue_tables ×2 ▉▉
manage_aws_athena_query_exec ×9 ▉▉▉▉▉▉▉▉▉
─────────────────────────────────────────
Total: 12 tool calls │ 6 event loop cycles │ ~27s
# Of the 9 Athena calls:
start-query-execution ×4 (multiple query attempts)
get-query-execution ×3 (polling for completion)
get-query-results ×2 (fetching results)
⚠ 2 of the 4 start-query-execution calls were redundant retries
Unlike a traditional reporting application where a query runs once per request, the agentic pattern introduces non-determinism into data access. The same question asked twice may produce different tool call sequences. Without hard limits, that non-determinism compounds at scale.
Why this matters at scale: Consider 100 store managers each asking 5 data questions a day. If unguarded, every redundant Athena query multiplies across users — that's how a $50/day scan bill becomes $500/day without anyone touching the code. A traditional reporting app runs the query once; an agentic one turns every question into a potential multiplication.
How Strands Hooks Work
Strands emits many hook event types across the agent lifecycle — AgentInitializedEvent, BeforeInvocationEvent, BeforeModelCallEvent, MessageAddedEvent, and more. Two of them are the workhorses for controlling tool-call behavior:
| Event | Mechanism | What it does |
|---|---|---|
BeforeToolCallEvent | event.cancel_tool = "reason" | Cancels the call. The reason string is delivered to the model as a tool result, so it sees why and can compose a final answer. |
AfterToolCallEvent | Mutate event.result["content"] | Rewrites what the model sees as the tool result. The original is gone from the model's view. |
The Handler Pattern
A handler is a Python class with a register_hooks method. Strands calls it once at agent construction. State lives on the instance and resets per invocation:
from strands import Agent from strands.hooks import HookRegistry from strands.hooks.events import BeforeToolCallEvent, AfterToolCallEvent MAX_TOOL_CALLS = 10 MAX_CONSECUTIVE_FAILURES = 3 MAX_QUERY_EXECUTIONS = 2 class AgentHooksHandler: def __init__(self) -> None: self.tool_call_count = 0 self.consecutive_failures = 0 self.query_execution_count = 0 def register_hooks(self, registry: HookRegistry, **kwargs) -> None: registry.add_callback(BeforeToolCallEvent, self._before_tool) registry.add_callback(AfterToolCallEvent, self._after_tool) def _before_tool(self, event: BeforeToolCallEvent, **kwargs) -> None: ... def _after_tool(self, event: AfterToolCallEvent, **kwargs) -> None: ... # Wire into the Agent agent = Agent( model=bedrock_model, tools=mcp_tools, system_prompt=system_prompt, hooks=[AgentHooksHandler()], )
A fresh handler instance is created per agent invocation, so counters reset between user requests.
Four Production Patterns
Pattern A: Authorization-Failure Sanitization (AfterToolCallEvent)
When any tool call returns an authorization error, log the verbatim error to CloudWatch for operators, and replace the result the model sees with a sanitized message.
AUTHORIZATION_MARKERS = ( "AccessDenied", "AccessDeniedException", "is not authorized to perform", "UnauthorizedAccess", ) SANITIZED_MESSAGE = ( "[AUTHORIZATION FAILURE — DO NOT IMPROVISE] " "A permission issue prevented this operation from completing. " "Tell the user a permission issue prevented their request. " "Do NOT relay IAM action names, ARNs, or error codes. " "Do NOT fabricate, guess, or estimate an answer." ) def _after_tool(self, event: AfterToolCallEvent, **kwargs) -> None: result_text = str(event.result.get("content", "")) exception = event.exception is_auth_failure = ( (exception and any(m in str(exception) for m in AUTHORIZATION_MARKERS)) or any(m in result_text for m in AUTHORIZATION_MARKERS) ) if is_auth_failure: # Operator gets full detail in CloudWatch logger.warning( "Hooks: auth failure on tool=%s. Detail: %s", event.tool_use.get("name"), result_text[:1000] ) # Model sees only the sanitized instruction event.result["status"] = "error" event.result["content"] = [{"text": SANITIZED_MESSAGE}] self.consecutive_failures += 1 return self.consecutive_failures = 0
| Metric | Without Hooks | With Hooks |
|---|---|---|
| Total invocation duration | 27.4s (26 spans) | 21.7s (21 spans) |
| Failing tool calls before stopping | 2 (Athena:StartQueryExecution, then a fallback Athena:GetWorkGroup) | 1 (Athena:StartQueryExecution only) |
| Redundant retries after failure | 1 | 0 |
| Information leaked | IAM action names, missing-permission list, offer to modify the role | Nothing |
| User clarity | Confusing (technical detail plus unsafe "want me to fix it?" offer) | Single, actionable message |
Figure 4: With hooks — the tool span now carries the sanitized instruction
[AUTHORIZATION FAILURE — DO NOT IMPROVISE]. Beneath it, Message 1: Hooks: authorization failure on tool=manage_aws_at... shows the WARN log the operator sees, with the verbatim IAM error preserved for debugging.
# Span log record from Transaction Search
# (trace 6a4e2e2b...d488, span fc1fe5b9...0e76)
severity: WARN scope: data_explorer_agent.hooks
Hooks: authorization failure on tool=manage_aws_athena_query_executions
operation=start-query-execution.
Original detail: Error in manage_aws_athena_queries: An error occurred
(AccessDeniedException) when calling the StartQueryExecution operation:
You are not authorized to perform: athena:StartQueryExecution on the
resource. | Exception: None
Figure 5: The agent proceeds through its normal workflow — understanding the question, discovering the relevant data, and planning the query — but the moment it attempts execution, the sanitized signal takes over: "apologize, but a permission issue prevented your request from running. Please contact the workshop facilitator to help resolve this access issue." No IAM action names, no ARNs, no offer to fix anything.
Pattern B: Budget Cap (BeforeToolCallEvent)
Stop runaway discovery loops by capping tool calls per invocation:
def _before_tool(self, event: BeforeToolCallEvent, **kwargs) -> None: self.tool_call_count += 1 if self.tool_call_count > MAX_TOOL_CALLS: event.cancel_tool = ( f"BUDGET: Tool call limit ({MAX_TOOL_CALLS}) reached. " "Summarize what you have found so far. Tell the user the " "budget was reached and offer to continue with a more " "focused follow-up question." ) return
The cancellation message is delivered to the model as the tool result. The model composes a final answer with whatever it learned, and the loop ends cleanly.
Figure 6: Budget cap in action (
MAX_TOOL_CALLS=4). The BUDGET message replaces what would have been the 5th tool call, and the WARN log confirms Hooks: tool call limit reached (count=5).
# Span log records from Transaction Search
# (trace 6a4e30ce...3a29)
severity: WARN scope: data_explorer_agent.hooks
Hooks: tool call limit reached (count=5)
severity: INFO scope: data_explorer_agent.hooks
Hooks: non-auth failure on tool=manage_aws_athena_query_executions
operation=get-query-execution (consecutive_failures=1)
Figure 6b: The agent's user-facing response after the cap fires. It does not stall or apologise abstractly — it names the situation ("Budget Reached: I've hit the tool call limit for this request before the query finished executing") and offers three concrete follow-ups: continue and fetch full results, focus on one dimension, or narrow the scope. This is what the
BUDGET cancellation message directed the model into producing.
Pattern C: Fail-Fast (BeforeToolCallEvent)
Stop the agent from grinding when something is genuinely broken:
if self.consecutive_failures >= MAX_CONSECUTIVE_FAILURES: event.cancel_tool = ( f"FAIL_FAST: {MAX_CONSECUTIVE_FAILURES} consecutive failures. " "Stop calling tools. Tell the user a problem is preventing " "the request from completing. Do NOT improvise an answer." ) return
The consecutive_failures counter is incremented in _after_tool on every failure and reset to zero on any successful tool call. After N failures in a row, the agent stops and reports honestly.
Pattern D: Operation-Level Limits (BeforeToolCallEvent)
Sometimes the limit isn't on the tool itself but on a specific operation within it. For example, you want to allow the agent to call manage_aws_athena_query_executions freely for get-query-execution and get-query-results, but limit expensive start-query-execution calls to 2 per invocation.
The BeforeToolCallEvent gives you access to the full tool input including the operation parameter:
# Limit expensive query executions specifically tool_input = event.tool_use.get("input", {}) or {} if (event.tool_use.get("name") == "manage_aws_athena_query_executions" and tool_input.get("operation") == "start-query-execution"): self.query_execution_count += 1 if self.query_execution_count > MAX_QUERY_EXECUTIONS: event.cancel_tool = ( f"QUERY_LIMIT: You have already started " f"{MAX_QUERY_EXECUTIONS} queries this request. " "Do NOT start another query. Instead, retrieve " "results for your existing queries using " "get-query-execution and get-query-results, then " "present your findings to the user." ) return
Key insight: This doesn't block the tool — it blocks only the expensive operation. The agent can still fetch results for its existing queries. The cancellation message explicitly redirects the model to retrieve what it already started rather than stopping dead.
Figure 7: Operation-level limit in action (
MAX_QUERY_EXECUTIONS=2). The QUERY_LIMIT message replaces the tool call at count=3 and again at count=4 as the model tries a parallel batch. The right pane shows the WARN log line the operator sees. The agent isn't blocked from all tools — it can still call get-query-execution and get-query-results for the two queries it already started.
# Span log records from Transaction Search
# (trace 6a4e367c...a3cb)
severity: WARN scope: data_explorer_agent.hooks
Hooks: query execution limit reached (count=3)
severity: WARN scope: data_explorer_agent.hooks
Hooks: query execution limit reached (count=4)
Figure 7b: The agent's response after the limit fires. Instead of stopping, the model rereads the situation ("I've already started 2 queries in this conversation and have their results — let me present those findings first"), pivots to results retrieval, and presents the first two patterns it discovered. The single hook turned a runaway multi-query exploration into a productive partial answer.
Cancellation Messages Are Prompts, Not Just Enforcement
Look at Figures 5, 6b, and 7b again. The [AUTHORIZATION FAILURE — DO NOT IMPROVISE], BUDGET, and QUERY_LIMIT cancellation messages don't just stop the agent — they instruct it. The model reads each cancellation as a tool result and follows it into a graceful degradation: contact the facilitator, summarise what you have and offer next steps, present partial findings. The same rules that bound compute also shape a better user experience.
Verifying with CloudWatch
Add the AWS OpenTelemetry Distro to your agent's requirements.txt:
strands-agents>=1.0.1
strands-agents-tools>=0.2.1
aws-opentelemetry-distro>=0.10.0
When the agent runs on Amazon Bedrock AgentCore Runtime no code change is required. The runtime detects the distro and emits spans to the aws/spans CloudWatch log group.
Two views into the same data:
- CloudWatch → GenAI Observability → Bedrock AgentCore — visual span tree per invocation. Every screenshot in this article (Figs 4, 6, 7) is from this dashboard. Each hook-cancelled tool call shows the cancellation body in the span error panel and the
Hooks:WARN log record inline underneath. - CloudWatch Logs Insights on
aws/spans— programmatic access to the same span data. Filter by session ID to find the trace, filter by trace ID to list its spans, filter@message like /Hooks:/to surface every hook signal the runtime emitted. Useful when you want to correlate a Chainlit session to its traces, aggregate across many invocations, or extract log records for offline analysis.
Compare a trace from a no-hooks run to one with hooks attached: the first shows the agent freely retrying failing tool calls, leaking IAM detail, or exploring far beyond what the question needed; the second shows those same impulses being cancelled, sanitized, or redirected inline. Together they make agent behaviour observable end to end — what the model tried to do, and what the hooks did in response.
Conclusion
System prompts and IAM are two separate layers of control, and neither is sufficient alone. The system prompt guides what the model tries to do. IAM controls what AWS will let it actually do. But the gap between an IAM error and a user-facing response is where agents, by default, behave badly: leaking infrastructure detail, fabricating answers, looping wastefully, and burning tokens on redundant operations.
Strands hooks close that gap with deterministic Python code that runs every time, regardless of what the model wants to say. The four patterns shown here — authorization sanitization, budget caps, fail-fast, and operation-level limits — cover the most common production risks. The pattern generalizes: any time the model receives a structured signal it shouldn't relay or interpret freely, intercept it with a hook before it gets there.
Combined with OpenTelemetry tracing on AgentCore Runtime, you get both enforcement and observability — control over what the agent does, and proof that it did it.
References
- Strands Agents Hooks Documentation
- Amazon Bedrock AgentCore Runtime
- AWS OpenTelemetry Distro
- CloudWatch GenAI Observability
Tags: Amazon Bedrock, Strands Agents, Amazon Bedrock AgentCore, Observability, CloudWatch, AI Agents, Production Readiness
- Language
- English
Relevant content
asked a year ago
