Every agent we build stops before it sends. The certificate draft waits for a CSR. The remarket packet waits for the producer. The service-request reply waits for whoever owns the account. That is not a product decision, it is an E&O decision, and it has to hold when the agent is running unattended at 2am against 400 open items.
The hard part is not the pause. It is pausing for three days without holding a process open, without losing the work in progress, and without a second click quietly re-sending the same certificate. AWS Step Functions has a built-in answer: the callback pattern, .waitForTaskToken. This tutorial wires one up: a state machine that drafts, pauses, waits for a human decision, and then either sends or files the rejection.
You need an AWS account, permission to create Step Functions state machines and Lambda functions, and a place to put the approval action, a Teams message, an email with two links, or a small internal page. The queue interface does not matter here; the state machine does.
Why a callback and not a poll
The obvious implementation is a loop: write the draft to a table with status = pending, and have something check every few minutes to see whether a human touched it. It works, and it drifts. Retries duplicate rows, timeouts are invented per workflow, and the audit trail lives in whatever the last engineer wrote.
The callback pattern inverts it. Step Functions hands your task a token, then the execution stops and costs nothing while it waits. When the CSR clicks Approve, your endpoint calls SendTaskSuccess with that token and the execution resumes at the next state. State, timeout, and history are the platform's problem. The service integration patterns documentation covers the mechanics.
One constraint before you build: the callback pattern requires a Standard workflow. Express workflows do not support .waitForTaskToken, and a five-minute maximum duration was never going to cover a CSR on PTO.
The state machine
Four states: draft, wait for a decision, send, or file the rejection.
{
"Comment": "COI draft with human approval",
"StartAt": "DraftCertificate",
"States": {
"DraftCertificate": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"FunctionName": "${DraftFunctionArn}",
"Payload.$": "$"
},
"ResultSelector": { "draft.$": "$.Payload" },
"ResultPath": "$.drafting",
"Next": "AwaitApproval"
},
"AwaitApproval": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke.waitForTaskToken",
"TimeoutSeconds": 259200,
"Parameters": {
"FunctionName": "${NotifyFunctionArn}",
"Payload": {
"taskToken.$": "$$.Task.Token",
"requestId.$": "$.requestId",
"draft.$": "$.drafting.draft"
}
},
"ResultPath": "$.approval",
"Catch": [
{
"ErrorEquals": ["States.Timeout"],
"ResultPath": "$.error",
"Next": "EscalateToManager"
}
],
"Next": "SendCertificate"
},
"SendCertificate": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"FunctionName": "${SendFunctionArn}",
"Payload.$": "$"
},
"End": true
},
"EscalateToManager": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"FunctionName": "${EscalateFunctionArn}",
"Payload.$": "$"
},
"End": true
}
}
}
Read AwaitApproval carefully. The Lambda it invokes does not decide anything. It writes the pending item and the token somewhere durable and posts the notification, then returns. The execution stays paused until something calls back with that token, or until TimeoutSeconds expires, three days here, and the Catch routes it to a supervisor instead of leaving it to rot.
Store the token with the item
The notify function's whole job is to make the token findable later.
import os, json, boto3
ddb = boto3.client("dynamodb")
TABLE = os.environ["APPROVALS_TABLE"]
def handler(event, context):
ddb.put_item(
TableName=TABLE,
Item={
"requestId": {"S": event["requestId"]},
"taskToken": {"S": event["taskToken"]},
"draft": {"S": json.dumps(event["draft"])},
"status": {"S": "PENDING"},
},
ConditionExpression="attribute_not_exists(requestId) OR #s = :pending",
ExpressionAttributeNames={"#s": "status"},
ExpressionAttributeValues={":pending": {"S": "PENDING"}},
)
notify_reviewer(event["requestId"], event["draft"])
return {"notified": True}
Tokens are long, over 1,000 characters, so keep them out of URLs and query strings. The approval link carries the requestId; the token is looked up server-side. That also means a forwarded email cannot approve anything on its own.
Resume the execution
The endpoint behind Approve and Reject does three things in order: flip the row, call Step Functions, and refuse to do either twice.
import boto3
from botocore.exceptions import ClientError
sfn = boto3.client("stepfunctions")
def approve(request_id, reviewer, decision, note=""):
try:
item = ddb.update_item(
TableName=TABLE,
Key={"requestId": {"S": request_id}},
UpdateExpression="SET #s = :decided, reviewer = :r, note = :n",
ConditionExpression="#s = :pending",
ExpressionAttributeNames={"#s": "status"},
ExpressionAttributeValues={
":decided": {"S": decision},
":pending": {"S": "PENDING"},
":r": {"S": reviewer},
":n": {"S": note},
},
ReturnValues="ALL_NEW",
)["Attributes"]
except ClientError as e:
if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
return "already decided"
raise
token = item["taskToken"]["S"]
if decision == "APPROVED":
sfn.send_task_success(
taskToken=token,
output=json.dumps({"decision": decision, "reviewer": reviewer}),
)
else:
sfn.send_task_failure(
taskToken=token, error="Rejected", cause=note or "no reason given"
)
return "ok"
The conditional update is the idempotency guard. A double-click, a retried webhook, or the CSR opening the link on a phone and a laptop all hit the same condition and the second one does nothing. Without it, SendTaskSuccess on a spent token throws TaskTimedOut and you are debugging a duplicate certificate on a Friday.
Note the asymmetry: rejection uses send_task_failure. The rejection is not an error in the operational sense, but modelling it as a failed task means the execution history records it plainly, and you can Catch Rejected into a state that files the reviewer's note as an activity in the AMS. The reason a certificate was not issued is worth keeping.
What to log
Whatever your reviewers approve, the record has to reconstruct the decision months later. At minimum, per item: the source document or message id, the AMS account and policy the agent matched to, the endorsements or fields it checked, the draft as presented, the reviewer's identity, the decision, the timestamp, and the note. Step Functions keeps execution history for 90 days; that is a debugging tool, not your record. Write the decision into the AMS activity log, where the account's history already lives.
Limits worth knowing before you commit
- The callback pattern is Standard workflows only.
- A task can wait up to one year, but a Standard execution's own limit is one year too. Set a real
TimeoutSecondswell under it and escalate on expiry. - Task tokens are single-use. After success, failure, or timeout, the token is dead; treat
TaskTimedOutfromSendTaskSuccessas "the human was too late", not as a bug to retry. - If a queue can sit paused for days, add
HeartbeatSecondsand a heartbeat only where a long-running worker, not a human, holds the token. Humans do not send heartbeats.
Where this fits
This pattern is the spine under every agent we ship: the agent does the reading, matching, and drafting, and a licensed person makes the send decision with the sources in front of them. It is also the part that makes an unattended overnight run defensible, because nothing left the building without a name attached.
We build these approval paths inside the agency's own AWS account as part of COI Agent and Service-Request Agent work. If you are wiring one up and want a senior engineer on it, contact us.