Four lifecycle hook checkpoints in the AgentCore harness agent loop A request enters, passes checkpoint 1 before the invocation, then the agent loop repeats: the model picks a tool, checkpoint 2 before the tool call, the tool runs, checkpoint 3 after the tool call. When done, checkpoint 4 after the invocation, then the response. A Lambda hook can return allow or deny; SNS and EventBridge hooks can only report. Four checkpoints in the agent loop At each one the harness can ask something outside the loop whether to carry on. Request 1 THE AGENT LOOP Model picks a tool 2 Tool runs 3 repeat until the task is done 4 Response 1 before_invocation deny stops the task before it starts 2 before_tool_call deny skips this call; the loop carries on 3 after_tool_call deny keeps the result, then stops the task 4 after_invocation reports only; the answer has already gone LAMBDA HOOK A guard: returns allow or deny SNS / EVENTBRIDGE HOOK A camera: records, can’t stop anything

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 a lifecycle hook can enforce, and where each one runs Consequential-action gate: before_tool_call, Lambda, deny and record a pending approval. Step budget: before_tool_call, Lambda, running count per session. Spend budget: before_invocation, Lambda, using token totals reported by the previous run. Argument check: before_tool_call, Lambda, amount, account, path or recipient. Audit trail: after_tool_call and after_invocation, SNS or EventBridge, logging rather than a control. Four controls, and where each one runs Three are decisions a Lambda makes. The audit trail is a record, not a control. CONTROL CHECKPOINT TARGET DECIDES AGAINST Consequential-action gate before_tool_call Lambda deny, record a pending approval Step budget before_tool_call Lambda running count per session Spend budget before_invocation Lambda token totals from the last run Argument check before_tool_call Lambda amount, account, path, recipient Audit trail after_tool_call · after_invocation SNS / EventBridge logging, not a control
Three gates and a camera. Only the gates are controls.

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

A denied agent tries again, so the check must use state the model cannot change Left, a check on one call's arguments with a 1,000 dollar limit: a 1,200 dollar reimbursement is denied, then two 600 dollar reimbursements are each allowed, so 1,200 dollars is paid. Right, a check on the session's running total: the 1,200 dollar call is denied, the first 600 is allowed, and the second 600 is denied because the session total would reach 1,200. A deny doesn’t stop the agent. It tries again. So the check has to be one the model can’t reason its way around. CHECKS ONE CALL’S ARGUMENTS reimburse($1,200) over the $1,000 limit deny reimburse($600) under the limit allow reimburse($600) under the limit allow $1,200 paid out. Every call passed. CHECKS STATE THE MODEL CAN’T CHANGE reimburse($1,200) over the session limit deny reimburse($600) session total: $600 allow reimburse($600) session total would be $1,200 deny Limit held. The running total doesn’t reset.
Deny against a running total, not against one call.

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

What a lifecycle hook cannot do, and where that control lives instead Hooks exist only in the managed harness; elsewhere use your own loop or AgentCore Policy at the Gateway. A hook cannot modify a call; use the tool's own code or a Gateway interceptor. A hook sees at most 64 KiB of context; check the arguments going in. For inline tools, after_tool_call only proves a result arrived; make consequential tools Gateway targets the harness calls. The Gateway remains the only door, and the SCP makes it the only door. What a hook can’t do, and where that control goes Every limit points somewhere specific. None of them points at the prompt. THE HOOK CAN’T SO PUT IT HERE Exist outside the managed harness not in your own Strands or LangGraph loop Your own loop, or AgentCore Policy evaluated at the Gateway on every tool call Modify a call no rewriting, redirecting or adding parameters The tool’s own code, or a Gateway interceptor See more than 64 KiB of context larger fields arrive truncated, with a preview Checks on the arguments going in not on the results coming out Prove an inline tool really acted after_tool_call only shows a result arrived Make the consequential tool a Gateway target that the harness itself calls The outer wall doesn’t change: the Gateway is the only door, and the SCP is what makes it the only door.
Each limit tells you where the rest of the control has to live.

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

Waiting for a human inside the hook versus approving from a queue Synchronous: the hook pages a person and holds the client stream open for up to 900 seconds; if the person is in a meeting the hook times out and the default failure mode denies; if the stream drops there is no record of the approval. Asynchronous: the hook denies and records a pending approval, returns at once, a person approves from a queue in their own time, the action runs from the approval record with the approver's identity, and the agent is re-invoked with the result if needed. Two ways to wait for a human Waiting inside the hook is a demo. Waiting in a queue is an approval you can show an auditor. SYNCHRONOUS · THE HOOK WAITS The hook pages a person, then waits The client stream is held open, up to 900 s Person in a meeting: timeout, so deny Stream drops: no record of any approval Like waiting at your manager’s desk. ASYNCHRONOUS · THE QUEUE 1 Hook denies and records a pending approval 2 It returns at once; nothing is held open 3 A person approves from a queue, in their own time 4 The action runs from the record, with their name 5 The agent is re-invoked with the result Like putting the form in their inbox.
The approval belongs in a record, not in an open connection.

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.