There's a lot of excitement about what makes AI agents powerful — they reason, they choose their own tools, they decide their own next step. But that same autonomy is what makes them hard to run in production. An agent that decides its own actions is great in a demo. In production, "the model decided to" is not something you want to say when something goes wrong.
So the real question is: when the agent is running on its own, who is actually in control?
This is what I mean by harnessing the agent. Not taking away its autonomy, but putting a layer around it that keeps the final authority with you. The model still reasons and still decides. But you get to set the boundaries it operates inside. In Strands, that layer is hooks.
Hooks let you step into the agent's loop at specific moments — before it calls the model, before it runs a tool, after a tool returns, at the start and end of a request. You're not just watching from the outside. You can actually intervene and change what happens next. That's the important part. Observability tells you what the agent did. Hooks let you decide what it's allowed to do.
Here are three ways I think about using them, and they build on each other.
First, you bound it.
One of the most common problems is an agent getting stuck in a loop, calling the same tool again and again. On its own that sounds harmless. In production it turns into real cost, rate limits you didn't plan for, and behaviour you can't rely on. The agent isn't broken — it's just doing what it decided to do, more times than you'd ever want.
A hook fixes this cleanly. You count how many times each tool is called within a single request, and once it goes over a limit, you cut it off with a message the model actually understands.
class ToolCallLimiter(HookProvider):
def __init__(self, ceiling: int = 3):
self.ceiling = ceiling
self.calls: dict[str, int] = {}
def register_hooks(self, registry: HookRegistry) -> None:
registry.add_callback(BeforeInvocationEvent, self.on_start)
registry.add_callback(BeforeToolCallEvent, self.guard)
def on_start(self, event: BeforeInvocationEvent) -> None:
self.calls = {} # fresh count each request
def guard(self, event: BeforeToolCallEvent) -> None:
tool = event.tool_use["name"]
self.calls[tool] = self.calls.get(tool, 0) + 1
if self.calls[tool] > self.ceiling:
event.cancel_tool = (
f"'{tool}' hit its {self.ceiling}-call limit this request. "
"Stop calling it."
)
agent = Agent(
tools=[get_order_status, lookup_customer, check_inventory],
hooks=[ToolCallLimiter(ceiling=3)],
)
The flow is simple. on_start runs at the beginning of every request and resets the counter, so the limit is per request and not global. guard runs before each tool call, counts it, and once a tool goes past its ceiling, cancel_tool stops it and tells the model to move on. In this example any tool can be called at most three times in one request — after that, the agent has to do something else.
But here's the part that actually matters: the counter isn't the point. The point is that this same layer — the moment right before a tool runs — is where cost control, rate limiting, guardrails, and audit logging all end up living. And it lives there separate from your tool code. Your tools stay simple and focused on doing their job. The control sits in one place, around the agent, where you and your team can see it.
That's the real value of bounding. You've put a ceiling on the agent's autonomy without touching a single line of the tool itself.
Second, you constrain it.
Bounding handles how often the agent acts. But sometimes the problem isn't the number of calls — it's the call itself. The agent reaches the right tool, but chooses a parameter you'd never want it to use. Maybe it asks for far more results than your system should return, or sets a value that's fine in a demo but wrong in production. The agent is doing its job, it's just making a choice that shouldn't be its choice to make.
This is where you constrain it. Instead of leaving certain arguments up to the model, you fix them yourself — right before the tool runs.
class FixToolArguments(HookProvider):
def __init__(self, fixed: dict[str, dict]):
self.fixed = fixed
def register_hooks(self, registry: HookRegistry) -> None:
registry.add_callback(BeforeToolCallEvent, self.apply)
def apply(self, event: BeforeToolCallEvent) -> None:
name = event.tool_use["name"]
if name in self.fixed:
# the model doesn't get to choose these — you do
event.tool_use["input"].update(self.fixed[name])
agent = Agent(
tools=[search],
hooks=[FixToolArguments({"search": {"max_results": 5}})],
)
The idea is straightforward. apply runs before each tool call. If the tool is one you've decided to control, you overwrite the arguments the model picked with the values you want. In this example, no matter what the model asks for, the search tool always runs with max_results set to 5. The model still decides when to search and what to search for — but the parameters that carry real cost or risk are set by you, not by it.
This is a small change with a big effect. You're drawing a line between the decisions you're happy to let the model make, and the ones that belong to the system. The agent keeps its usefulness, but it now operates inside rules it can't invent its way around.
And again, notice where this lives — the same layer, right before the tool runs. Bounding and constraining aren't two separate systems. They're two callbacks on the same layer around the agent, doing different jobs.
Third, you gate it.
Some actions should never happen without a human. Anything that changes real data or can't be undone — cancelling an order, sending a message, deleting a record — is a different kind of risk from the first two. Bounding and constraining keep the agent inside limits, but the agent still acts on its own. For irreversible actions, that's not enough. You want a person in the loop before it happens, not after.
This is where you gate it. A hook can pause the agent right before a sensitive tool runs and hand control back for approval. Only once a human says yes does the action go through.
class RequireApproval(HookProvider):
def __init__(self, guarded_tools: set[str]):
self.guarded_tools = guarded_tools
def register_hooks(self, registry: HookRegistry) -> None:
registry.add_callback(BeforeToolCallEvent, self.gate)
def gate(self, event: BeforeToolCallEvent) -> None:
tool = event.tool_use["name"]
if tool in self.guarded_tools:
event.interrupt(
"approval_needed",
reason=f"Approve running '{tool}'?",
)
agent = Agent(
tools=[get_order_status, cancel_order],
hooks=[RequireApproval(guarded_tools={"cancel_order"})],
)
The logic is simple. gate runs before every tool call. If the tool is one you've marked as sensitive, event.interrupt pauses the agent and asks for approval before letting it continue. Read-only tools like checking an order status pass straight through. Only the action that actually changes something — here, cancelling an order — is stopped and held until a human decides.
This is the strongest form of control of the three. With bounding and constraining, the agent still acts by itself, just inside your limits. With gating, the agent simply cannot take an irreversible step on its own. A person is in the loop by design, not by luck. For anything that touches real customers or real money, that's often exactly what you want.
Credit where it's due — a couple of these patterns clicked for me watching Strands series on the AWS Developers channel. Worth a look if you're working with agents.
How are you thinking about control around your agents? I'd be curious to hear what others are doing.
Top comments (0)