Step Functions Multi-Agent: Multi-Agent Coding Pipelines With Human Approval Gates

Step Functions: Multi-Agent Coding Pipelines With Human Approval Gates
8 views

This guide covers Step Functions multi-agent with production-focused examples and trade-offs.

Multi-agent coding pipelines fail in production the same way distributed jobs fail: unbounded retries, silent partial success, and no human interrupt when the plan goes sideways. Step Functions is the right control plane—not because it “runs agents,” but because it gives you retries with backoff, Map fan-out, task tokens for human approval, and a durable execution history you can replay in a postmortem.
⚡ TL;DR: Model the pipeline as Plan → Implement → Test → Review with explicit state machine transitions. Use Map for file batches, Catch/Retry on Bedrock throttles, and a Task Token human gate before any merge or infra mutate. Emit execution ARNs into PR comments. Never let an agent auto-merge. Pair with LLM Coding Agents on AWS: Safe Tool Sandboxes with Lambda and Bedrock Agents: Idempotent Tool Calls Against DynamoDB Writes.

State machine shape that seniors actually ship — Step Functions multi-agent

Keep stages coarse. Fine-grained “think” states explode cost and make approvals unusable.
json
{
  "Comment": "Multi-agent coding pipeline",
  "StartAt": "PlanAgent",
  "States": {
    "PlanAgent": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": {
        "FunctionName": "agent-plan",
        "Payload": {
          "ticket.$": "$.ticket",
          "repoSha.$": "$.repoSha"
        }
      },
      "ResultPath": "$.plan",
      "Next": "HumanApprovePlan",
      "Retry": [{
        "ErrorEquals": ["Bedrock.ThrottlingException", "States.Timeout"],
        "IntervalSeconds": 5, "MaxAttempts": 4, "BackoffRate": 2.0
      }]
    },
    "HumanApprovePlan": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke.waitForTaskToken",
      "TimeoutSeconds": 86400,
      "HeartbeatSeconds": 3600,
      "Parameters": {
        "FunctionName": "notify-slack-approval",
        "Payload": {
          "taskToken.$": "$$.Task.Token",
          "plan.$": "$.plan",
          "executionArn.$": "$$.Execution.Id"
        }
      },
      "ResultPath": "$.approval",
      "Next": "ImplementMap"
    },
    "ImplementMap": {
      "Type": "Map",
      "ItemsPath": "$.plan.Payload.fileBatches",
      "MaxConcurrency": 3,
      "Iterator": {
        "StartAt": "ImplementBatch",
        "States": {
          "ImplementBatch": {
            "Type": "Task",
            "Resource": "arn:aws:states:::lambda:invoke",
            "Parameters": {
              "FunctionName": "agent-implement",
              "Payload.$": "$"
            },
            "End": true
          }
        }
      },
      "ResultPath": "$.patches",
      "Next": "TestAgent"
    },
    "TestAgent": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": {
        "FunctionName": "agent-test",
        "Payload": { "patches.$": "$.patches", "repoSha.$": "$.repoSha" }
      },
      "ResultPath": "$.test",
      "Next": "ReviewGate"
    },
    "ReviewGate": {
      "Type": "Choice",
      "Choices": [{
        "Variable": "$.test.Payload.passed",
        "BooleanEquals": true,
        "Next": "OpenPrOnly"
      }],
      "Default": "FailClosed"
    },
    "OpenPrOnly": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": {
        "FunctionName": "open-draft-pr",
        "Payload": {
          "patches.$": "$.patches",
          "neverMerge": true
        }
      },
      "End": true
    },
    "FailClosed": {
      "Type": "Fail",
      "Error": "TestsFailed",
      "Cause": "Agent tests failed — no PR opened"
    }
  }
}
✅ Human token before implement; draft PR only; never gh pr merge from the state machine.
❌ Auto-merge on green tests from an unattended agent.

Human approval that is interruptible

typescript
// notify-slack-approval.ts
import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs";

export async function handler(event: {
  taskToken: string;
  plan: { Payload: { summary: string; blastRadius: string[] } };
  executionArn: string;
}) {
  // ✅ Park the token; humans resume via Slack action → SendTaskSuccess
  await new SQSClient({}).send(new SendMessageCommand({
    QueueUrl: process.env.APPROVAL_QUEUE!,
    MessageBody: JSON.stringify({
      taskToken: event.taskToken,
      executionArn: event.executionArn,
      summary: event.plan.Payload.summary,
      files: event.plan.Payload.blastRadius,
      expiresAt: Date.now() + 86_400_000,
    }),
  }));
}

// resume.ts — Slack interactivity handler
import { SFNClient, SendTaskSuccessCommand, SendTaskFailureCommand } from "@aws-sdk/client-sfn";

export async function resume(decision: "approve" | "reject", taskToken: string, actor: string) {
  const sfn = new SFNClient({});
  if (decision === "reject") {
    await sfn.send(new SendTaskFailureCommand({
      taskToken,
      error: "HumanRejected",
      cause: `rejected_by:${actor}`,
    }));
    return;
  }
  await sfn.send(new SendTaskSuccessCommand({
    taskToken,
    output: JSON.stringify({ approvedBy: actor, at: new Date().toISOString() }),
  }));
}
Tie approvals to change tickets the same way you would for on-call remediations in LLM Incident Runbooks: Ground On-Call Answers in CloudWatch Signals.

Observability: execution ARN is the correlation ID

typescript
// open-draft-pr.ts — stamp the PR with the Step Functions execution
const body = [
  `## Agent pipeline`,
  `- Execution: \`${executionArn}\``,
  `- Plan approval: ${approval.approvedBy}`,
  `- Tests: ${test.Payload.summary}`,
  ``,
  `Do **not** merge until CODEOWNERS review. See [Agentic Git Workflows](https://cheatcoders.net/agentic-git-workflows-atomic-commits-from-noisy-llm-diffs/).`,
].join("\n");
CloudWatch metrics to alarm on: ExecutionsFailed, ExecutionsTimedOut, human-gate age > 24h, Map concurrency saturation. Trace Bedrock hops with the patterns in OpenTelemetry for LLMs.

Cost and throttle controls inside the Map

yaml
# Cap fan-out and budget tokens per batch
MaxConcurrency: 3
# Inside agent-implement Lambda:
# - application inference profile
# - per-execution token budget from $.plan.Payload.tokenBudget
Budget enforcement belongs at the org layer too — see LLM Cost Controls: Token Budgets Per PR and Per Engineer.

Closing checklist

  • [ ] State machine has Plan → HumanApprove → Implement(Map) → Test → DraftPR only
  • [ ] waitForTaskToken with 24h timeout and heartbeat; reject path calls SendTaskFailure
  • [ ] Bedrock throttles use Retry with backoff; no infinite loops
  • [ ] Map MaxConcurrency capped; per-execution token budget enforced
  • [ ] PR body includes execution ARN; merge is human-only
  • [ ] Alarms on failed/timed-out executions and stale approvals

Related reading

Last updated on September 11, 2026

Related: Day 33: Agents on Step Functions, Not Infinite Loops · Lambda Durable Patterns: Avoid Step Functions Until Complexity Earns It · Idempotency Keys End-to-End: API Gateway Through Step Functions Safely

Related: Step Functions Express Workflows: Orchestrate Lambda at 100K Events Per Second · How to Build a Real-Time Fraud Detection and Decisioning Architecture on AWS with EventBridge, Step Functions, and DynamoDB · AWS Multi-Tenant SaaS Control Plane Architecture: API Gateway, ECS, DynamoDB, Step Functions, and EventBridge

Watch: 100 Days of AI on YouTube

Short videos from CheatCoders, one AI engineering topic per day.

Open the playlist on YouTube · CheatCoders on YouTube

Deep-dive PDF

Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.

Already subscribed? or open the subscribe page.


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a comment

No account needed. Name and email are optional.