Your coding agent needs to plan, call read_file, fan out three search tools, wait for a human to approve a git_push, then summarize. The naive pattern — a Lambda that invokes itself with InvocationType=Event until depth hits a ceiling — is how teams burn concurrency, lose stack traces, and invent homemade circuit breakers. Step Functions already solved orchestration: state machines with Map/Parallel, Express vs Standard cost models, and waitForTaskToken for human-in-the-loop. Use that instead of teaching every agent runtime to be a scheduler.
⚡ TL;DR: Model tool graphs as Step Functions (Express for high-throughput tool fan-out, Standard when you need human approval or runs >5 minutes). Prefer Map/Parallel over recursive Lambda. Pair with Lambda Recursive Loop Protection, AppConfig kill switches, and AI Coding Agent Tool Schemas.
waitForTaskTokenbeats polling DynamoDB for approvals.
Express vs Standard for agent tool graphs
Pick the workflow type from the failure and duration profile of the agent turn — not from habit.
| Need | Prefer | Why |
|---|---|---|
| Sub-second tool fan-out, >100k starts/day | Express | At-least-once, 5 min max, cheap per-transition |
| Human approval mid-graph, runs >5 min | Standard | Exactly-once execution semantics, 1 year max, history retained |
| Mix (fast tools then slow review) | Standard outer + Express nested | Nest Express for Map fan-out inside Standard |
// ❌ Recursive self-invoke "orchestrator" — depth, quotas, silent drops
import { LambdaClient, InvokeCommand } from "@aws-sdk/client-lambda";
const lambda = new LambdaClient({});
export async function agentStep(event: { depth: number; messages: unknown[] }) {
if (event.depth >= 8) throw new Error("max depth");
const toolCalls = await planTools(event.messages);
for (const call of toolCalls) {
await runTool(call);
}
// hope nothing double-fires, hope DLQ exists, hope CloudWatch catches it
await lambda.send(
new InvokeCommand({
FunctionName: process.env.AWS_LAMBDA_FUNCTION_NAME!,
InvocationType: "Event",
Payload: Buffer.from(JSON.stringify({ ...event, depth: event.depth + 1 })),
})
);
}
✅ Move the graph into ASL. The Lambda becomes a worker, not a scheduler. Recursive Loop Protection still matters for accidental self-invokes — see Lambda Recursive Loop Protection — but it should not be your primary orchestrator.
Map and Parallel for tool fan-out
When the model returns three independent tool calls (read_file × 2 + grep), do not serialize them in a single Lambda. Use Map (dynamic cardinality) or Parallel (fixed branches).
{
"Comment": "Coding agent tool graph — Express-friendly fan-out",
"StartAt": "PlanTools",
"States": {
"PlanTools": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"FunctionName": "arn:aws:lambda:us-east-1:123456789012:function:agent-planner",
"Payload.$": "$"
},
"ResultSelector": { "toolCalls.$": "$.Payload.toolCalls" },
"Next": "FanOutTools"
},
"FanOutTools": {
"Type": "Map",
"ItemsPath": "$.toolCalls",
"MaxConcurrency": 5,
"Iterator": {
"StartAt": "InvokeTool",
"States": {
"InvokeTool": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"FunctionName": "arn:aws:lambda:us-east-1:123456789012:function:agent-tool-runner",
"Payload.$": "$"
},
"Retry": [
{
"ErrorEquals": ["States.TaskFailed"],
"IntervalSeconds": 1,
"MaxAttempts": 2,
"BackoffRate": 2.0
}
],
"End": true
}
}
},
"ResultPath": "$.toolResults",
"Next": "Summarize"
},
"Summarize": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"FunctionName": "arn:aws:lambda:us-east-1:123456789012:function:agent-summarizer",
"Payload.$": "$"
},
"End": true
}
}
}
// ✅ Tool runner stays dumb — schema + AppConfig gate only
import { z } from "zod";
const ToolCall = z.object({
name: z.enum(["read_file", "grep", "apply_patch", "shell_exec"]),
args: z.record(z.unknown()),
idempotencyKey: z.string().min(8),
});
export async function handler(event: unknown) {
const call = ToolCall.parse(event);
// gate with AppConfig kill switches before side effects
if (!(await isToolEnabled(call.name))) {
return { ok: false, error: "tool_disabled", name: call.name };
}
return runToolIdempotent(call);
}
Cap MaxConcurrency so a buggy planner cannot open 200 shell tools at once. Combine with tool contracts from AI Coding Agent Tool Schemas.
waitForTaskToken: human-in-the-loop without busy-wait
For git_push, deploy_prod, or any irreversible tool, pause the graph until a human (or a secondary policy service) calls SendTaskSuccess / SendTaskFailure.
// ✅ Standard workflow task with callback token
// ASL snippet concept: Resource = arn:aws:states:::lambda:invoke.waitForTaskToken
import {
SFNClient,
SendTaskSuccessCommand,
SendTaskFailureCommand,
} from "@aws-sdk/client-sfn";
const sfn = new SFNClient({});
// Approval API (API Gateway → Lambda) called by your review UI
export async function approvePush(req: {
taskToken: string;
approved: boolean;
reviewer: string;
}) {
if (req.approved) {
await sfn.send(
new SendTaskSuccessCommand({
taskToken: req.taskToken,
output: JSON.stringify({ approvedBy: req.reviewer, at: new Date().toISOString() }),
})
);
} else {
await sfn.send(
new SendTaskFailureCommand({
taskToken: req.taskToken,
error: "HumanRejected",
cause: `rejected_by=${req.reviewer}`,
})
);
}
}
Store the taskToken in DynamoDB keyed by session/PR id with a TTL. Do not embed the raw token in a public URL without signing. Heartbeat with SendTaskHeartbeat if the approval window is long — otherwise Standard workflows time out the task.
❌ Polling a approvals DynamoDB table from a Lambda every 5 seconds while the state machine sits in a Wait state wastes money and races. Prefer callback tokens.
Contrast: recursive Lambda vs Step Functions
| Concern | Recursive Lambda | Step Functions |
|---|---|---|
| Visibility | CloudWatch logs per hop | Execution history / Map runs |
| Retries | DIY | Declarative Retry/Catch |
| Human pause | Homemade | waitForTaskToken |
| Cost at high QPS | Lambda duration × hops | Express transitions cheaper |
| Loop protection | Required (post) | Native graph; still guard workers |
Keep Recursive Loop Protection on for tool Lambdas that might still self-invoke by bug. Kill switches in AppConfig still gate which tools the Map iterator may call.
Error handling and Catch paths
Agent graphs fail in two ways: model/planner errors (bad JSON, empty tool list) and tool errors (patch conflict, sandbox timeout). Model ASL Catch separately:
{
"Catch": [
{
"ErrorEquals": ["ToolSchemaError"],
"ResultPath": "$.error",
"Next": "RespondValidationError"
},
{
"ErrorEquals": ["States.ALL"],
"ResultPath": "$.error",
"Next": "RespondTransientError"
}
]
}
Surface validation errors to the user immediately; route transient tool failures through retries then a friendly “partial results” summarize state. Do not recurse the whole agent Lambda to “try again” — that is how you rediscover why Recursive Loop Protection exists.
Operational checklist
- [ ] Choose Express vs Standard from duration + human-in-the-loop needs
- [ ] Use Map with
MaxConcurrencyfor fan-out tool calls - [ ] Put irreversible tools behind
waitForTaskToken+ signed approval API - [ ] Keep tool Lambdas idempotent (
idempotencyKeyin payload) - [ ] Leave Recursive Loop Protection enabled on workers; do not orchestrate via self-invoke
- [ ] Wire AppConfig kill switches into the tool runner, not only the planner
- [ ] Alert on
ExecutionsFailedand Map item failures separately from agent “model errors”
Related reading
- Lambda Recursive Loop Protection: Stop Agent Self-Invokes From Burning Quotas
- AppConfig Feature Flags: Kill Switches for Agent Tools Without Redeploy
- AI Coding Agent Tool Schemas: Strict JSON Contracts That Survive Retries
- CodeBuild Spec Sandboxes for AI Coding Agents Without ECS Sprawl
Last updated on September 20, 2026
Most viewed
- Python Decorators Explained: From Simple Wrappers to Production Patterns
- LLM context windows: Tokens, Context Windows, and Why Models Forget Mid-Task
- Zero-Copy Node Streams: Pipe Large S3 Objects Without Buffering
- SQL Joins Explained: INNER, LEFT, RIGHT, FULL, CROSS, and Self Joins
- PostgreSQL Performance Tuning: The Configuration Changes That Actually Matter
Newly added
- API Gateway + WAF: Rate-Limit Public Coding Agent Endpoints
- AWS Verified Permissions: Cedar Policies That Authorize Agent Tools
- ADOT OpenTelemetry: Trace Multi-Hop Agent Tool Calls Across Lambda
- ElastiCache Redis: Scratchpads and Tool-Result Cache for Multi-Turn Coding Agents
- Step Functions: Orchestrate Multi-Step Coding Agent Graphs Without Recursive Chaos
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.