English

The first version of our claims-triage agent lived entirely in a long-running Lambda: call Bedrock, parse the response, decide the next step, loop. It worked in the demo and fell apart the first time a reviewer took four hours to approve a flagged claim, because Lambda has a 15-minute ceiling and our "agent" had no memory of where it was when it got killed. Step Functions turned out to be the missing piece — not because it's exciting, but because it makes long-lived, resumable, auditable agent state someone else's problem.

Why a State Machine Fits Agent Workflows

Agent frameworks love to describe themselves as loops: think, act, observe, repeat. In production that loop has to survive process restarts, wait indefinitely for a human, and prove after the fact exactly what happened at each step. A state machine gives you all three for free:

  • Durability — execution state is persisted by the service, not held in a process's memory
  • Native waiting — a state can pause for hours or days without holding compute
  • Auditability — every transition, input, and output is logged and queryable

Step Functions models this as an Amazon States Language (ASL) definition — a JSON graph of states — instead of imperative code, which turns your agent's control flow into something you can diagram, version, and diff in a PR.

Invoking Bedrock from a Lambda Task State

Each "thinking" step is a Lambda task state that calls Bedrock and returns a structured decision.

// lambda/invoke-agent-step.ts
import { BedrockRuntimeClient, ConverseCommand } from '@aws-sdk/client-bedrock-runtime';

const bedrock = new BedrockRuntimeClient({ region: 'us-east-1' });

export const handler = async (event: { claimId: string; history: Message[] }) => {
  const response = await bedrock.send(
    new ConverseCommand({
      modelId: 'anthropic.claude-4-5-sonnet-v2:0',
      messages: event.history,
      toolConfig: {
        tools: [
          {
            toolSpec: {
              name: 'triage_decision',
              description: 'Decide the next action for a claim',
              inputSchema: {
                json: {
                  type: 'object',
                  properties: {
                    action: { enum: ['auto_approve', 'escalate', 'request_docs'] },
                    reasoning: { type: 'string' },
                  },
                  required: ['action', 'reasoning'],
                },
              },
            },
          },
        ],
      },
    }),
  );

  const toolUse = response.output?.message?.content?.find((c) => c.toolUse);
  return { decision: toolUse?.toolUse?.input, claimId: event.claimId };
};

The state machine wraps this task with retries scoped to the failure modes Bedrock actually throws:

{
  "Retry": [
    {
      "ErrorEquals": ["ThrottlingException", "ModelTimeoutException"],
      "IntervalSeconds": 2,
      "MaxAttempts": 5,
      "BackoffRate": 2.0
    },
    {
      "ErrorEquals": ["States.TaskFailed"],
      "IntervalSeconds": 1,
      "MaxAttempts": 2
    }
  ],
  "Catch": [
    {
      "ErrorEquals": ["States.ALL"],
      "Next": "EscalateToHuman",
      "ResultPath": "$.error"
    }
  ]
}

Human Approval with Task Tokens

The escalate branch pauses the entire execution — no polling, no Lambda idling — using the waitForTaskToken service integration pattern.

{
  "RequestApproval": {
    "Type": "Task",
    "Resource": "arn:aws:states:::lambda:invoke.waitForTaskToken",
    "Parameters": {
      "FunctionName": "notify-reviewer-queue",
      "Payload": {
        "claimId.$": "$.claimId",
        "taskToken.$": "$$.Task.Token"
      }
    },
    "TimeoutSeconds": 259200,
    "Next": "ApplyDecision"
  }
}

The notify-reviewer-queue Lambda drops the task token into a review dashboard's database row instead of using it immediately. When a human clicks "approve" days later, a separate API handler calls SendTaskSuccess with that stored token, and the state machine resumes exactly where it left off — no context lost, no re-running earlier steps.

await sfnClient.send(
  new SendTaskSuccessCommand({
    taskToken: storedToken,
    output: JSON.stringify({ approved: true, reviewerId: 'r-2291' }),
  }),
);

Observability: CloudWatch and X-Ray Together

Step Functions emits execution history automatically, but tracing across the Lambda-Bedrock-Lambda chain needs X-Ray stitched in:

import { captureAWSv3Client } from 'aws-xray-sdk-core';

const bedrock = captureAWSv3Client(new BedrockRuntimeClient({ region: 'us-east-1' }));

With X-Ray enabled on both the state machine and the Lambda functions, a single trace shows the full latency breakdown: time spent in the model call versus time spent waiting on the human approval task token versus retry backoff. CloudWatch Logs Insights then answers the operational questions that matter for an agent in production:

fields @timestamp, claimId, decision.action, latencyMs
| filter decision.action = "escalate"
| stats count(*) as escalations by bin(1h)

That query alone told us our escalation rate spiked every Monday morning — turned out the prompt's few-shot examples were all weekday-adjacent and the model was systematically less confident about claims filed over the weekend.

Wrapping Up

None of this is exotic AWS usage — Step Functions has supported task tokens since long before "agentic AI" was a marketing term. What's changed is that Bedrock gives you a first-class API to slot a foundation model into exactly one task state in that graph, so the durability, retry semantics, and human-in-the-loop pausing you'd have hand-rolled in a bespoke agent runtime come from infrastructure that's been battle-tested for a decade. If your agent needs to survive an hour-long human review step without burning compute or losing state, a state machine is very often the least clever, most reliable answer.

0
0
0
0