There is a moment in every AI agent project where the demo works and someone asks the obvious next question: are we really going to let this thing act on its own? The agent that drafts emails is fun. The agent that sends them is a policy decision. The agent that pays invoices is a governance problem wearing a chatbot costume.
I have spent a good part of this year building agents that hold real credentials, including one with an actual wallet, and the design question that matters most is never which model to use. It is where to put the approval gate: the point where the agent stops, a human looks at what it intends to do, and either lets it through or does not. This post is about implementing that gate on AWS serverless, and specifically about why Lambda durable functions, announced at re:Invent 2025, changed my default architecture for it.
The shape of the problem
An approval gate sounds trivial until you try to build one. The hard part is not the yes-or-no logic, it is the waiting. An agent decides at 2 p.m. that it wants to spend 400 dollars. The human who can approve that is in a meeting, then on a train, and taps the approve button at 9 the next morning. Your system has to hold that pending decision for nineteen hours, survive deployments and failures in the meantime, resume exactly where it left off, and ideally cost nothing while it sits there.
Before 2026, the standard AWS answer was a Step Functions state machine using the waitForTaskToken pattern. It works, and I have shipped it. But it forces an awkward split: the agent logic lives in your code, while the pause-and-resume logic lives in a JSON state machine definition, and every change to the flow means editing both and keeping them mentally synchronized. For a workflow whose entire structure is "do the thing, unless it is risky, in which case wait for a human," the ceremony always felt out of proportion.
Durable functions collapse that split. The waiting becomes a single call inside ordinary code, the function suspends without compute charges for up to a year, and an external signal wakes it up. The whole gate fits in one Lambda function you can read top to bottom.
The architecture

The system has four pieces, and only one of them is new.
The agent itself runs wherever it runs; in my case that is Amazon Bedrock AgentCore Runtime, but nothing here depends on it. When the agent wants to perform a sensitive action, it does not call the payment API or the delete API directly. Instead it invokes a durable Lambda function that owns the action, passing along what it wants to do and why.
That durable function first consults a policy. Mine is a DynamoDB table mapping action types and thresholds to one of three outcomes: allow, deny, or ask a human. A 3 dollar API purchase sails through, a 300 dollar one does not. Keeping the policy in a table rather than in code matters more than it seems, because the people who should tune these thresholds are usually not the people deploying Lambda functions.
When the outcome is ask, the function creates a callback, sends the human a notification containing the callback ID, and suspends. The notification can be a Slack message, an email, or a card in a web console; whatever it is, it carries enough context for a real decision, meaning what the agent wants to do, what it will cost, and the agent's own stated reasoning.
The final piece is a small responder, an API Gateway endpoint behind the approve and reject buttons. It does one thing: it tells Lambda to complete the callback with the human's verdict, which wakes the sleeping function to carry out or abandon the action.
The code
Here is the heart of the durable function, in Python. I have trimmed logging and error handling to keep the shape visible.
from aws_durable_execution_sdk import (
durable_execution, DurableContext, CallbackConfig, Duration
)
@durable_execution
def lambda_handler(event, context: DurableContext) -> dict:
action = event["action"]
decision = context.step(
lambda: evaluate_policy(action),
name="evaluate_policy",
)
if decision == "deny":
return {"status": "denied", "by": "policy"}
if decision == "ask":
callback = context.create_callback(
name="human_approval",
config=CallbackConfig(
timeout=Duration.from_hours(24),
),
)
context.step(
lambda: notify_approver(action, callback.callback_id),
name="notify_approver",
)
verdict = callback.result()
if verdict != "approved":
return {"status": "rejected", "by": "human"}
receipt = context.step(
lambda: execute_action(action),
name="execute_action",
)
return {"status": "completed", "receipt": receipt}
Every side effect is wrapped in a step, which gives it checkpointing and retries; if the function is interrupted after notifying the approver, replay will skip the notification rather than spam the human twice. The line that does the real work is the call to callback.result(). At that point the function checkpoints its state and disappears. There is no polling loop, no container idling at your expense, nothing to keep alive. Nineteen hours later, when the responder Lambda runs the following call, execution resumes on the very next line as if no time had passed.
lambda_client.send_durable_execution_callback_success(
CallbackId=callback_id,
Result="approved",
)
The IAM permissions for that call, SendDurableExecutionCallbackSuccess and its failure twin, are the security boundary of the whole system. Only the responder should hold them, and the responder should authenticate the human before using them. Guard those two permissions as carefully as you would guard the payment API itself, because holding them is equivalent to holding the approve button.
Timeouts are policy, not plumbing
The detail I would underline twice is the timeout on the callback. In this design, a request nobody answers within 24 hours fails, and the agent's action is abandoned. That is a deliberate choice of default deny: silence means no. You could invert it for low-stakes actions, approving automatically when the timeout fires, and the code change is a few lines. But that inversion is a governance decision someone should make consciously, not a default you back into. I keep the timeout values in the same DynamoDB policy table as the thresholds, so the question of how long a human has to answer sits next to the question of what needs a human at all.
The same thinking applies to the audit trail. Because every decision, whether by policy or by human, flows through one function, writing an append-only record to DynamoDB from inside it gives you a complete history of everything the agent attempted, what the policy said, who approved it, and when. When someone eventually asks why the agent was allowed to do something, and someone always eventually asks, that table is the answer.
When I would still reach for Step Functions
Durable functions did not make Step Functions obsolete, and pretending otherwise would be dishonest. If your approval flow spans many services with native integrations, if compliance people need to see the workflow as a diagram rather than trust your code, or if you want the execution history console that Step Functions gives you for free, the waitForTaskToken pattern remains solid and I would not migrate a working one. My rule is about ownership: when the workflow is really application logic that developers own end to end, it belongs in code, and durable functions let it live there. When the workflow is an integration diagram that several teams need to see and negotiate over, it belongs in Step Functions.
Closing thoughts
The industry conversation about agent safety tends to happen at high altitude, all frameworks and principles. What I like about the approval gate is how it brings that conversation down to something a builder can ship in an afternoon: one durable function, one policy table, one responder endpoint, and suddenly your agent's autonomy has an adjustable dial instead of an on-off switch. The interesting work then shifts to where it should be, deciding what sits above and below the line that requires a human. That line will move as trust grows. The architecture should make moving it a one-row change, and now it can be.
Sources: the Lambda durable functions documentation, the AWS Durable Execution SDK developer guide, and the launch post for durable functions.
Top comments (0)