10 Hidden Concepts in Strands SDK Hooks That You Won’t See by Reading the Code
9th March 2026
The Strands SDK hook system looks simple on the surface — register a callback, receive an event. But there are 10 hidden concepts buried in the design that you’ll never see by just reading the code. Here’s what’s actually happening under the hood.
1. The Event Object is a Two-Way Channel
It looks like you’re just passing data to a callback. But the event object goes both directions:
Event Loop ——creates event——→ Hook Function
Event Loop ←——reads modified event—— Hook Function
The hook doesn’t return anything. It modifies the object in place. This works because Python objects are passed by reference — the event loop and the hook are looking at the same object in memory.
before_event = BeforeToolCallEvent(cancel=False) # event loop creates it
hooks.invoke(before_event) # hook sets cancel=True on SAME object
if before_event.cancel: # event loop reads the SAME object
# tool is blocked
If events were immutable (like strings or tuples), the entire hook system wouldn’t work. The mutability IS the mechanism.
2. Hooks Have No Return Value — By Design
Every hook function returns None. This is intentional. If hooks returned values, who wins when two hooks disagree?
# WRONG approach (not how it works):
result1 = hook_a(event) # returns "allow"
result2 = hook_b(event) # returns "block"
# Now what? Who wins?
# ACTUAL approach (last writer wins):
hook_a(event) # sets event.cancel = False
hook_b(event) # sets event.cancel = True ← this wins because it ran last
Last hook to modify the event wins. Registration order matters. This is why the Strands SDK runs “After” events in reverse — so the first-registered hook (usually the most important one) gets the last word during cleanup.
3. Reverse Order is a Stack (Setup/Teardown Pattern)
Think of getting dressed and undressed:
MORNING (setup — forward order):
1. Put on underwear
2. Put on pants
3. Put on shoes
NIGHT (cleanup — REVERSE order):
3. Take off shoes ← must happen FIRST
2. Take off pants
1. Take off underwear ← must happen LAST
You can’t take off underwear before pants. Same with code:
# Setup hooks (Before events — forward order):
hook_A: open database connection
hook_B: start a transaction (needs the connection from A)
hook_C: start a timer
# Cleanup hooks (After events — REVERSE order):
hook_C: stop the timer
hook_B: commit the transaction (connection still open!)
hook_A: close database connection (transaction already committed!)
If cleanup ran forward (A→B→C), hook_A would close the database connection BEFORE hook_B could commit the transaction. Data lost. Reverse order guarantees each hook can clean up what it set up.
4. Hooks Can Talk to Each Other (Through the Event)
Two hooks registered for the same event share the same event object. They communicate through it like a shared whiteboard:
def guard_1(event: BeforeToolCallEvent):
"""Scans for suspicious keywords."""
if "drop table" in str(event.tool_input):
event.cancel_reason = "SQL injection detected"
# Flags it but does NOT cancel (wants second opinion)
def guard_2(event: BeforeToolCallEvent):
"""Makes the final decision."""
if event.cancel_reason: # reads what guard_1 wrote
event.cancel = True # guard_2 makes the call to block
Guard 1 and guard 2 don’t know each other exist. They never call each other. But they communicate through the event object — like two people writing sticky notes on the same package.
5. The Model Never Knows Hooks Exist
When a hook blocks divide(10, 0), the model gets back:
{"toolResult": {"content": [{"text": "Tool cancelled: Division by zero blocked"}]}}
The model thinks the tool ran and returned an error. It doesn’t know a hook intercepted it. From the model’s perspective, it asked for a tool and got a result back. The hook is invisible to the AI.
This means hooks can enforce rules the model cannot override or argue with. The model can’t say “please run it anyway” — it never knows the tool didn’t run.
6. AfterInvocationEvent ALWAYS Fires (Even on Crash)
try:
result = event_loop_cycle(messages, hooks)
hooks.invoke(AfterInvocationEvent(agent=agent, result=result))
except Exception as e:
hooks.invoke(AfterInvocationEvent(agent=agent, result=None, error=e)) # STILL fires
raise
This is the finally pattern. Cleanup hooks MUST run regardless of success or failure. If a hook opened a database connection in BeforeInvocationEvent, it needs AfterInvocationEvent to close it even if the agent crashed. Without this guarantee, hooks would leak resources on every error.
7. BeforeModelCallEvent Can Rewrite Reality
The most powerful hidden concept. BeforeModelCallEvent gives you messages and tool_specs as mutable lists. You can rewrite what the model sees:
Example: PII Redaction
def redact_pii(event: BeforeModelCallEvent):
"""Runs BEFORE the model sees the messages."""
import re
for msg in event.messages:
for block in msg["content"]:
if "text" in block:
block["text"] = re.sub(
r'\d{3}-\d{2}-\d{4}', '[SSN REDACTED]', block["text"]
)
# What happens:
# User typed: "My SSN is 123-45-6789, what tax bracket am I in?"
# Model receives: "My SSN is [SSN REDACTED], what tax bracket am I in?"
# The model NEVER sees the SSN.
Example: Hide Dangerous Tools at Night
def remove_tool_at_night(event: BeforeModelCallEvent):
import datetime
if datetime.datetime.now().hour >= 22:
event.tool_specs = [
t for t in event.tool_specs
if t["toolSpec"]["name"] != "delete_file"
]
# Model can't use delete_file because it doesn't know it exists.
# Like hiding the scissors from a child.
Example: Inject Context from a Database
def inject_context(event: BeforeModelCallEvent):
user_profile = get_from_database("current_user")
event.messages.insert(0, {
"role": "user",
"content": [{"text": f"Context: User is {user_profile['name']}, "
f"account type: {user_profile['plan']}"}]
})
# Model thinks the user provided this context.
# Actually the hook injected it from a database.
The key insight: event.messages and event.tool_specs are what gets sent to Bedrock. If you change them in the hook, the model sees your version, not the original. You control what reality the model lives in.
8. MessageAddedEvent is the Audit Trail No One Notices
It fires on EVERY message — user input, model response, tool results. It looks boring:
hooks.invoke(MessageAddedEvent(agent=None, message=tool_result_message))
But this single event enables:
- Conversation persistence — save every message to a database as it happens
- Real-time streaming — push each message to a WebSocket/SSE endpoint
- Compliance logging — immutable audit trail of every AI interaction
- Session management — the Strands
SessionManagerhooks into exactly this event
It’s the most frequently fired event and the one that enables the most infrastructure.
9. The Hook System IS the SDK
In the real Strands SDK, these built-in features are ALL implemented as hooks:
ConversationManager → hooks into MessageAddedEvent to trim old messages
SessionManager → hooks into MessageAddedEvent to persist to disk/S3
ModelRetryStrategy → hooks into AfterModelCallEvent, sets retry=True on throttle
Plugins → register hooks via HookProvider interface
They’re not special internal code. They use the same hooks.add_callback() you do. If you removed hooks, you’d lose conversation management, session persistence, retry logic, and plugins. The SDK would be just the bare event loop.
10. One Hook Can Transform the Entire Agent
# Read-only agent (can think but never act):
def block_all_tools(event: BeforeToolCallEvent):
event.cancel = True
# Structured-output agent:
def force_json(event: BeforeModelCallEvent):
event.messages.insert(0, {
"role": "user",
"content": [{"text": "You must respond in JSON only."}]
})
# Emergency stop:
def emergency_stop(event: BeforeModelCallEvent):
if some_external_condition():
event.messages.clear() # empty messages → model call fails → agent stops
Hooks transform the agent’s fundamental behavior without changing any agent code. A single hook can make an agent read-only, force structured output, or shut it down entirely.
The Interception Pattern
Every hook follows the same interception pattern:
Model decides → "run divide(10, 0)"
↓
BeforeToolCallEvent fires
↓
hook sets cancel=True
↓
divide() NEVER CALLED
↓
Error text sent back to model
↓
Model explains gracefully
The hook is a gatekeeper sitting between the model’s intent and your code’s execution. The model has no safety — it will happily request divide by zero. Your hook catches it before damage happens.
Key Concepts Summary
| Concept | One-Line Explanation |
|---|---|
| Mutable events | Hooks modify the event object in place — no return value needed |
| No return value | Last hook to write wins; avoids “who wins?” conflicts |
| Reverse cleanup | After events run in reverse so each hook can clean up what it set up |
| Event as blackboard | Hooks communicate through shared event properties, not direct calls |
| Model blindness | The AI never knows hooks exist — it can’t override them |
| Guaranteed cleanup | AfterInvocationEvent fires even on crash (like finally) |
| Reality rewriting | BeforeModelCallEvent lets you change what the model sees |
| Audit trail | MessageAddedEvent fires on every message — enables persistence and logging |
| Hooks ARE the SDK | Core features (sessions, retries, plugins) are built on hooks |
| Agent transformation | One hook can make an agent read-only, JSON-only, or shut it down |
More recent articles
- OpenUSD: Advanced Patterns and Common Gotchas. - 28th March 2026
- OpenUSD Mastery: From Composition to Pipeline — A SO-101 Arm Journey - 25th March 2026
- Learning OpenUSD — From Curious Questions to Real Understanding - 19th March 2026