Human in the loop is my most important safety line as soon as an AI agent stops just writing text and starts doing things that cost money or can’t be undone. A support agent that issues a credit of 5 euros is handy. The same agent refunding 480 euros after a cleverly worded complaint is a problem. Somewhere in between, a person needs to take a quick look.
In the article about the Step Functions AgentCore Gateway I mentioned in passing that approvals don’t belong in the agent’s toolbox. Today I’m building exactly that: a workflow that pauses, asks a human, escalates when nobody answers and only then continues. This time the infrastructure comes entirely from the Serverless Framework.
In short
With the “wait for callback” pattern, Step Functions can wait up to a year for an answer, and the waiting itself costs nothing. You can use it to build approvals for AI agents where the agent can only request an action but never confirm it. The biggest pitfall: the task token works like a master key and belongs neither in emails nor in the hands of the agent.
What you will learn in this article
- How
.waitForTaskTokenpauses a workflow and resumes it - Why the agent must never have the same permissions as the person who approves
- How to build deadlines and escalation with
TimeoutSecondsandCatch - A runnable project with the Serverless Framework, TypeScript and tests
When an AI agent should ask a human
Not every action needs an approval. If you require approval for everything, people will be clicking “yes” on autopilot after three days, and you end up with the effort but without the protection. That’s why I sort actions by two questions: Can it be undone, and how expensive is a mistake?
Suggesting a reply email or checking a status is something the agent may do on its own. The same goes for a small goodwill credit below a fixed limit, as long as it is logged. Anything above that, anything visible to the outside world and anything that deletes data goes through a human. And at the very top there is a limit above which the agent isn’t even allowed to ask.
In the example for this article, a fictional online bookshop runs a support agent. It may request credits. The rules for this don’t live in the prompt but in the workflow:
| Amount | What happens |
|---|---|
| up to 20 euros | The workflow applies the credit right away, decision “auto” |
| 20.01 to 500 euros | The support team must approve; after 4 hours it escalates to the team lead |
| above 500 euros | The workflow stops; the agent isn’t allowed to request this |
The difference from the prompt matters. A language model can forget a sentence like “ask before refunding more than 20 euros”, misread it, or be talked out of it through prompt injection. A choice rule in Step Functions can’t be persuaded.
How human in the loop works with Step Functions
Step Functions knows three ways to call a service. Usually the workflow calls something and moves on as soon as the response arrives. If you append .waitForTaskToken to the resource, something else happens: Step Functions creates a task token, hands it to the called service and pauses the step. It only continues once someone calls SendTaskSuccess or SendTaskFailure with exactly this token.
A task token is a long random string that identifies exactly one waiting step. Anyone who knows it and holds the IAM permission states:SendTaskSuccess can complete the step and pass any data as its result. That’s why I treat it like a password.
Waiting is free in Standard workflows. Billing is based on state transitions, according to the pricing page 0.000025 US dollars per transition in us-east-1. So a workflow that waits three days for an approval costs exactly as much as one that continues after three seconds. The upper limit for a single execution is one year. By the way, Express workflows don’t support the callback pattern at all.
🎬 Flow of an approval with Step Functions – this animated diagram only runs on my site: watch it on sascha-ms.de
The flow in the example
- The agent calls its “request credit” tool. The tool starts a workflow execution with order number, amount and reason.
- A choice rule checks the amount. Small amounts go straight to booking, very large ones stop.
- The
AskTeamstep invokes a Lambda function with.waitForTaskToken. It stores the token in DynamoDB and sends an SNS message that contains only an approval ID. - A person from the team decides. Their request goes, signed, to a second Lambda function, which looks up the token in the table and calls
SendTaskSuccess. - If there is no answer within four hours,
TimeoutSecondskicks in. ACatchroutes to theAskLeadstep, which asks the team lead. - If the team lead stays silent too, the workflow sends a final notice and ends with the error
ApprovalExpired. Nothing gets booked.
The agent can only start. The workflow handles approval, escalation and expiry.
What matters is what the agent can’t do in this picture. It may start the workflow and read the status of its own executions. It never sees a token, it can’t call the decision function, and SendTaskSuccess doesn’t appear in its role at all.
Step by step: human in the loop with the Serverless Framework
Prerequisites
- An AWS account in which you may create Lambda, Step Functions, DynamoDB, SNS and IAM roles
- Node.js 22 or newer and AWS CLI v2 with a working login
- Serverless Framework v4 (
npm i -g serverless); v4 needs a free account or a license key -
curl7.75 or newer andjqfor the approval script
Step 1: Create the project
mkdir credit-approval && cd credit-approval npm init -y npm i @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb @aws-sdk/client-sfn @aws-sdk/client-sns npm i -D typescript @types/node @types/aws-lambda vitest aws-sdk-client-mock asl-validator jsonata mkdir -p src statemachine test scripts Also set "type": "module" in your package.json. Serverless Framework v4 builds TypeScript handlers with esbuild during deployment, so you don’t need an extra plugin for that.
Step 2: Shared types and input validation
The decision will come from outside later. That’s why I validate it strictly before anything else happens: the approval ID must look like a UUID, the decision is either approved or rejected, and comments get truncated.
// src/approvals.ts export type Stage = 'team' | 'lead'; export interface CreditRequest { orderId: string; amountCents: number; reason: string; requestedBy: string; } export interface ApprovalTask { taskToken: string; stage: Stage; executionId: string; request: CreditRequest; } export interface Decision { approvalId: string; decision: 'approved' | 'rejected'; comment?: string; } // How long an entry is kept for auditing after the deadline export const KEEP_SECONDS = 7 * 24 * 60 * 60; export function parseDecision(body: string | undefined): Decision { if (!body) throw new Error('Body is missing'); const data = JSON.parse(body) as Partial<Decision>; if (typeof data.approvalId !== 'string' || !/^[0-9a-f-]{36}$/.test(data.approvalId)) { throw new Error('approvalId is missing or invalid'); } if (data.decision !== 'approved' && data.decision !== 'rejected') { throw new Error('decision must be "approved" or "rejected"'); } const comment = typeof data.comment === 'string' ? data.comment.slice(0, 500) : undefined; return { approvalId: data.approvalId, decision: data.decision, comment }; } export function formatEuro(cents: number): string { return new Intl.NumberFormat('en-IE', { style: 'currency', currency: 'EUR' }).format(cents / 100); } Step 3: Request the approval
Step Functions invokes this function with the token. It stores the token together with the request and sends a message to the team. Note the attribute_not_exists condition: it prevents a second call from overwriting an entry.
// src/request-approval.ts import { randomUUID } from 'node:crypto'; import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'; import { SNSClient, PublishCommand } from '@aws-sdk/client-sns'; import { type ApprovalTask, KEEP_SECONDS, formatEuro } from './approvals.js'; const db = DynamoDBDocumentClient.from(new DynamoDBClient({})); const sns = new SNSClient({}); export const handler = async (event: ApprovalTask): Promise<{ approvalId: string }> => { const approvalId = randomUUID(); const now = Math.floor(Date.now() / 1000); // The task token stays in DynamoDB. The message only contains the approvalId. await db.send(new PutCommand({ TableName: process.env.TABLE_NAME, Item: { approvalId, taskToken: event.taskToken, stage: event.stage, executionId: event.executionId, request: event.request, status: 'PENDING', createdAt: new Date(now * 1000).toISOString(), expiresAt: now + KEEP_SECONDS, }, ConditionExpression: 'attribute_not_exists(approvalId)', })); const { orderId, amountCents, reason, requestedBy } = event.request; await sns.send(new PublishCommand({ TopicArn: process.env.TOPIC_ARN, Subject: `Approval needed: credit of ${formatEuro(amountCents)} for ${orderId}`, Message: [ `The support agent (${requestedBy}) wants to issue a credit.`, `Order: ${orderId}`, `Amount: ${formatEuro(amountCents)}`, `Reason: ${reason}`, `Stage: ${event.stage === 'team' ? 'Team' : 'Team lead (escalation)'}`, '', `Approval ID: ${approvalId}`, 'Decide with: ./decide.sh <approval ID> approved|rejected "comment"', ].join('\n'), MessageAttributes: { stage: { DataType: 'String', StringValue: event.stage } }, })); return { approvalId }; }; Why not just put the token into a link, as many examples do? Because emails get forwarded, sit in mailboxes and show up in the logs of mail gateways. An approval ID on its own is useless to anyone. The token stays in the table, and only the decision function may read it.
Step 4: Receive the decision
This is the heart of the pattern. The function sits behind a function URL with AWS_IAM authentication. Every request must therefore be signed with valid AWS credentials, and Lambda tells you in the event who signed it. I use that for three checks.
// src/decide.ts import { DynamoDBClient, ConditionalCheckFailedException } from '@aws-sdk/client-dynamodb'; import { DynamoDBDocumentClient, GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; import { SFNClient, SendTaskSuccessCommand, TaskTimedOut, InvalidToken } from '@aws-sdk/client-sfn'; import type { APIGatewayProxyResultV2, LambdaFunctionURLEventWithIAMAuthorizer } from 'aws-lambda'; import { parseDecision } from './approvals.js'; const db = DynamoDBDocumentClient.from(new DynamoDBClient({})); const sfn = new SFNClient({}); const reply = (statusCode: number, body: object): APIGatewayProxyResultV2 => ({ statusCode, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), }); export const handler = async (event: LambdaFunctionURLEventWithIAMAuthorizer): Promise<APIGatewayProxyResultV2> => { // With AuthType AWS_IAM, this tells you who signed the request const decidedBy = event.requestContext.authorizer?.iam?.userArn; if (!decidedBy) return reply(403, { message: 'IAM signature required' }); let input; try { input = parseDecision(event.isBase64Encoded && event.body ? Buffer.from(event.body, 'base64').toString('utf8') : event.body); } catch (error) { return reply(400, { message: (error as Error).message }); } const table = process.env.TABLE_NAME; const { Item } = await db.send(new GetCommand({ TableName: table, Key: { approvalId: input.approvalId } })); if (!Item) return reply(404, { message: 'Approval not found' }); // Four-eyes principle: whoever requested the credit must not approve it if (decidedBy === Item.request.requestedBy) { return reply(403, { message: 'You cannot approve your own request' }); } // Only one decision per approval: the first click wins try { await db.send(new UpdateCommand({ TableName: table, Key: { approvalId: input.approvalId }, UpdateExpression: 'SET #s = :decided, decidedBy = :by, decidedAt = :at', ConditionExpression: '#s = :pending', ExpressionAttributeNames: { '#s': 'status' }, ExpressionAttributeValues: { ':decided': input.decision.toUpperCase(), ':pending': 'PENDING', ':by': decidedBy, ':at': new Date().toISOString(), }, })); } catch (error) { if (error instanceof ConditionalCheckFailedException) { return reply(409, { message: 'Someone has already decided' }); } throw error; } try { await sfn.send(new SendTaskSuccessCommand({ taskToken: Item.taskToken, output: JSON.stringify({ decision: input.decision, decidedBy, comment: input.comment ?? '' }), })); } catch (error) { if (error instanceof TaskTimedOut || error instanceof InvalidToken) { // Deadline passed or workflow already moved on (e.g. escalated) await db.send(new UpdateCommand({ TableName: table, Key: { approvalId: input.approvalId }, UpdateExpression: 'SET #s = :expired', ExpressionAttributeNames: { '#s': 'status' }, ExpressionAttributeValues: { ':expired': 'EXPIRED' }, })); return reply(410, { message: 'The deadline for this approval has passed' }); } throw error; } return reply(200, { message: `Decision saved: ${input.decision}` }); }; The first check is the four-eyes principle: whoever requested the credit must not approve it. The second is a conditional update in DynamoDB. If two people click at the same moment, the first one wins and the second gets a 409. The third check deals with expired deadlines. If the step has already timed out and escalated, Step Functions answers with TaskTimedOut. The function then marks the entry as expired and returns a 410.
For the four-eyes principle to work, requestedBy has to be set by the code of the agent tool, for example from the role the tool runs with. If you let the language model fill in this value itself, the check is worthless.
Step 5: Apply the credit
In the example, applying the credit is a placeholder. One detail is still worth it: the credit key is derived from order number and amount. If Step Functions retries the step after an error, no duplicate credit is created, provided your accounting system uses the key as an idempotency key.
// src/apply-credit.ts import { createHash } from 'node:crypto'; import type { CreditRequest } from './approvals.js'; interface ApplyCreditEvent { request: CreditRequest; approval: { decision: string; decidedBy: string; comment?: string }; } export const handler = async (event: ApplyCreditEvent) => { // Placeholder for your accounting system. The key makes retries idempotent. const creditId = createHash('sha256') .update(`${event.request.orderId}:${event.request.amountCents}`) .digest('hex') .slice(0, 16); console.log(JSON.stringify({ msg: 'Credit applied', creditId, ...event })); return { status: 'credited', creditId, orderId: event.request.orderId, approvedBy: event.approval.decidedBy }; }; Step 6: The workflow in JSONata
I write the workflow in the JSONata query language. It’s the newer of the two options in Step Functions and reads much more pleasantly than the old path expressions with .$. You get the token via $states.context.Task.Token. With Assign I keep the original request and the decision in variables that are available in all later steps.
{ "Comment": "Credit requested by an AI agent with human approval and escalation", "QueryLanguage": "JSONata", "StartAt": "Init", "States": { "Init": { "Type": "Pass", "Assign": { "request": "{% $states.input %}" },Source:https://dev.to/sascha-ms/human-in-the-loop-for-ai-agents-with-step-functions-2pn5Related posts
What I check before I trust a lot file

