Agents and architecture
The Approval Gate Finally Has a Place to Live: AgentCore Lifecycle Hooks
Four posts in this series have circled the same gap. The MCP post said consequential tool calls need an approval gate: the agent proposes, a human disposes. The harness post listed that gate among the things AgentCore didn’t give you and was “still yours to design.” The identity post argued the permission check has to sit where the model can’t reach it, because a check in the prompt is a check a poisoned document can rewrite. And the seven decisions post kept returning to one idea: control belongs in a reviewed path outside the agent.
All of that described a mechanism that existed only in pieces. AgentCore Policy could already block a tool call by rule at the Gateway, and was starting to express approvals. What was missing was a point inside the agent loop itself, between the agent deciding to act and the action happening, where your own code gets a vote. Think of a bank’s dual-control rule: a teller can start a large withdrawal, but the money only moves when a second person signs off. In September, AgentCore harness shipped that point, under the unglamorous name “lifecycle hooks.” This post is about what they are, what you can actually enforce with them, what you can’t, and how teams will get them wrong.
Opinions here are my own and don’t represent my employer. AgentCore details are as of October 2026 and move quickly; check the docs before relying on any specific one.
What a hook is
The managed harness runs the agent loop for you: call the model, dispatch the tool it chose, feed the result back, repeat. A lifecycle hook is a checkpoint in that loop, where the harness pauses and asks something outside the loop whether to continue. There are four checkpoints: before the invocation starts, before each tool call, after each tool call, and after the invocation ends.
A harness can have up to twenty hooks in total, across those four checkpoints, and they come in two kinds. A Lambda hook (a small function you write) is synchronous: the harness sends it an event, waits, and gets back a JSON decision of allow or deny with an optional reason. It’s a guard who can lower the barrier. An SNS or EventBridge hook (AWS messaging services that pass the event along) is notification only: it’s told what happened and can’t change anything. It’s a security camera: it records, and it can’t stop anyone. In the documentation’s own words, Lambda is “the only target type that can change agent-loop behavior.”
That’s a deliberately narrow mechanism. A hook can’t rewrite the tool’s arguments, can’t substitute a different tool, and can’t inject text into the conversation. It can say no, and it can tell someone. The narrowness is what makes it a control rather than another place for logic to hide.
The event the Lambda receives is small and specific. For a before_tool_call it carries the hook’s name, the harness, the session ID, the tool’s name and type, the arguments the model generated, and a tool-use ID to correlate with the trace. That’s the whole surface. Everything a hook decides, it decides from that.
What a hook can enforce
Four controls fit this mechanism exactly, and between them they cover most of what a risk partner asks for.
The consequential-action gate. A before_tool_call hook sees the tool name and arguments. If the tool is on your consequential list (anything that writes, sends, pays, or deletes), the Lambda denies the call and writes a pending-approval record: the arguments, the session, the time, the reason. A person approves it somewhere else, and the action executes from that approval, never from the agent. The agent proposes; the human disposes. This is the gate the MCP post asked for, and for the first time it sits in a place the model’s prompt can’t touch.
The step and spend budget. A before_tool_call hook fires before every tool call, so a Lambda that keeps a running count per session, in a database such as DynamoDB, can deny the next call once a task has used up its step allowance.
Spend works on a slower clock: token counts only arrive in the after_invocation event, once a run has finished, so a spend limit takes effect at the start of the next run, through a before_invocation deny, not halfway through the current one. Either way the budget lives outside the agent, so a confused agent in a loop stops because the platform said so, not because the model noticed. The repeated-tokens post made the FinOps case for this; the hook is where it becomes enforceable.
The argument check. Because the hook sees the arguments before the call runs, it can refuse ones that are out of bounds: an amount above a threshold, an account that isn’t the caller’s, a path outside the permitted directory, a recipient outside the domain. The model chose the arguments. The hook decides whether they’re acceptable. This is the enforcement half of the identity post’s “what an identity may do once it arrives.”
The audit trail. SNS or EventBridge on after_tool_call and after_invocation gives you a record of every tool call and every completed task: tool, result, session, stop reason, token usage. That record is what the observability post said must exist and be enforced rather than encouraged. One caveat the docs are explicit about: a delivery failure on a notification hook doesn’t stop the agent. The response stream only confirms that the harness sent the notification, not that anything received it, and standard SNS topics and EventBridge don’t guarantee the events arrive in order. So this is logging, and should be treated as logging; the controls are the Lambda hooks.
Here is the shape of a hook Lambda that does the first three. It’s shorter than the explanation.
CONSEQUENTIAL = {"approve_reimbursement", "send_email", "delete_record"}
MAX_TOOL_CALLS = 12
def handler(event, _):
if event["event"] != "before_tool_call":
return {"decision": "allow"}
ctx = event["context"]
session = event["sessionId"]
tool, args = ctx["toolName"], ctx.get("toolInput", {})
if args.get("truncated"):
return {"decision": "deny", "reason": "Tool input too large to evaluate."}
if count_and_increment(session) > MAX_TOOL_CALLS:
return {"decision": "deny", "reason": "Session step budget exhausted."}
if tool in CONSEQUENTIAL:
record_pending_approval(session, tool, args, ctx["toolUseId"])
return {"decision": "deny",
"reason": f"{tool} requires human approval; request recorded."}
if tool == "lookup_account" and not owned_by_session(session, args.get("account_id")):
return {"decision": "deny", "reason": "Account is not the caller's."}
return {"decision": "allow"}
The truncation check matters: the docs cap the event at 64 KiB, and a hook that can’t see the whole input should refuse rather than guess. The three helper functions are where your organization’s rules live, and they’re the part that’s actually yours.
Three properties that decide how you build on it
It fails closed. If the Lambda times out, throws, or returns something malformed, the harness applies deny unless you explicitly configure the failure mode to allow. This is the right default, for the same reason a secure door locks rather than unlocks when the power fails, and you should leave it. A control that fails open is a control that an outage silently removes, and “the hook was down so the agent just proceeded” is not a sentence you want in a post-mortem.
A deny on a tool call doesn’t stop the agent. It skips that call and the loop continues; the reason is returned in the response stream, so the client (and possibly the model) sees it. That means a denied agent may try again: a different tool, a different phrasing, an amount just under the threshold. It’s the employee who is told an expense over $1,000 needs approval, and submits two expenses of $600 instead.
This is the single most important design implication. The judgment can’t live only in the Lambda’s logic about one call, because the model will probe it. The hook has to deny against something the model can’t reason around: a per-session budget that doesn’t reset, a pending-approval record that already exists, an allowlist of tools rather than a blocklist of arguments. And the consequential tool itself must be reachable only through a path the hook guards; if the agent can call it some other way, the hook is a suggestion.
The event carries the session, not the user. The payload has a session ID, the tool, and the arguments. It doesn’t have the caller’s identity, the delegation, or a trace ID. It’s like a hotel key card: it opens room 412, but it doesn’t tell the door who you are. Matching the room to the guest is the front desk’s job.
If your audit record is going to say who asked, which the identity post argued it must, something on your side has to map the session to the user and the agent at session start, and the hook has to look that up. The three-identity model doesn’t arrive in the event. The hook is where you apply it, not where you get it.
What a hook can’t do
The limits are as useful to know as the capabilities, because each one tells you where the rest of the control has to live.
Hooks exist only in the managed harness. A Strands or LangGraph agent you run yourself on AgentCore Runtime doesn’t get them. For that agent, the same four controls go either in your own loop (which is code you can review, and the harness post’s argument for building your own) or at the Gateway, where AgentCore Policy evaluates rules on every tool call that passes through it. Either way the control boundary is the same one this series has drawn since the beginning: the Gateway is the only door, and the SCP is what makes it the only door. The hook is a convenience for the managed path, not a replacement for that boundary.
A hook can’t modify. It can’t lower an amount to the threshold, redirect a call to a safer tool, or add a parameter. If you want that behavior, it belongs in the tool’s own implementation or in a Gateway interceptor (a function that runs on every call passing through the Gateway), not in the hook.
A hook can’t see what it isn’t sent. The event context is capped at 64 KiB, and large fields are truncated with a preview. A hook that needs to inspect a large tool result in full isn’t going to get it; design the check around the arguments going in, which are small, rather than the results coming out.
And an after_tool_call hook for an inline tool (one the client executes and reports back) confirms only that the harness received a result, not that the client performed the action. For anything consequential, that’s not good enough. The consequential tool should be a Gateway target the harness itself calls, so the hook sees a call that actually happens.
The two mistakes everyone will make
Putting the judgment inside the Lambda. The tempting design is a hook that reads the arguments and reasons about them: is this amount reasonable, is this recipient plausible, does this look like what the user asked for. Some of that is fine. But a hook whose decision depends on the arguments alone is a hook the model will learn to satisfy, because a deny returns a reason and the loop continues. The durable version decides against state the model can’t change: the budget already spent, the approval already pending, the allowlist the platform team wrote. The hook is a gate, not a reviewer. Reviewers are people.
Calling a notification a control. It’s easy to attach SNS to every event, point it at a dashboard, and tell the risk committee the agent is “monitored.” It isn’t controlled. A camera records the break-in; it doesn’t lock the door. A notification that fails doesn’t stop anything, a notification that succeeds doesn’t prevent anything, and a dashboard nobody is watching at 2 a.m. is a log.
The risk committee question from the risk committee post applies here precisely: can you evidence that the control fired and the action didn’t happen? Only a Lambda deny gives you that evidence. Use notifications for the audit trail and Lambda for the gate, and don’t let the two blur in the deck.
The synchronous temptation
One detail in the docs is worth calling out because someone will build on it. A Lambda hook can be configured with a timeout up to 900 seconds, and the harness holds the response stream open while it waits. That means you could, technically, implement human approval synchronously: the hook pages a person, waits up to fifteen minutes for a click, and returns allow.
Don’t. It’s the difference between standing at your manager’s desk until they sign, and putting the form in their inbox. A session held open for a human’s attention is a session that times out when the human is in a meeting, that holds a client connection open for up to fifteen minutes, and that has no record of the approval if the stream drops. With the default failure mode, that timeout is simply a deny. The docs’ own setup for long waits is a hint about how fragile this is: a client read timeout above 900 seconds, plus TCP keepalive tuning if the connection passes through a NAT gateway or load balancer.
The asynchronous design is more work and much better: the hook denies, records the pending approval, and returns immediately; the human approves from a queue on their own time; the approved action executes from the approval record, with the approver’s identity on it; and if the agent needs to continue, it’s re-invoked with the result. That’s an approval you can show an auditor. The synchronous version is a demo.
Where this leaves the series
The approval gate now has a managed place to live, and it’s a good one: outside the prompt, fail-closed, with a clean event and a clean decision. What it doesn’t do is make the decisions. Which tools are consequential, what the budget is, which arguments are in bounds, who may approve, and how the session maps to a person are all still yours, exactly as the AgentCore in practice post said about every other primitive. The plumbing got cheaper. The judgment didn’t move.
What changes is that you no longer have an excuse. For a year “we’d need to build the gate” was a reason a consequential agent wasn’t ready for production. For agents on the managed harness, the gate is forty lines of Lambda and a line of configuration. The rest was always the hard part, and it still is.