AI agents are often portrayed as needing custom servers and endless glue code. In reality, the entire plan‑act‑observe cycle can be modeled as a deterministic state machine that runs fully managed. This post shows you how to wire Claude’s responses, a tool‑call API, and feedback handling together using Step Functions and API Gateway.
What Is the Plan‑Act‑Observe Loop?
Why it matters – An AI agent is just a computer program that decides what to do, does it, then looks at the result and decides the next move. This three‑step rhythm—plan, act, observe—is the engine behind everything from chat assistants that run code to robots that navigate a warehouse. If we can make the loop explicit, we gain the ability to retry, log, and scale each step without hand‑crafted orchestration.
Key terms
- Plan – a text prompt that tells a language model (LLM) what goal to pursue and which tool it might need.
- Act – an external operation, such as calling a micro‑service, running code, or hitting a database.
- Observe – feeding the result of the act back into the LLM so it can adjust its next plan.
Think of the loop like a choose‑your‑own‑adventure book. Each page (state) tells the reader (the model) what options exist, the reader makes a choice (act), then opens the next page (observe) based on the outcome.
In plain English – The loop is just “ask → do → check” repeated until the goal is reached.
Mapping the Loop to Step Functions States
Why Step Functions? – AWS Step Functions is a fully managed service that lets you describe a workflow as a state machine. Each state can call a Lambda, invoke a Bedrock model, or make an HTTP request. The service already gives you retries, timeout handling, and visual execution history, which means you don’t have to write that plumbing yourself.
How the mapping looks
| Loop stage | Step Functions state type | What it does |
|---|---|---|
| Plan |
Task (Bedrock InvokeModel) |
Sends a prompt to Claude and receives a plan JSON |
| Act |
Task (API Gateway HTTP integration) |
Calls the external tool (e.g., a code‑execution service) |
| Observe |
Pass + Choice
|
Sends the tool’s output back to Claude, decides whether to loop again |
The whole cycle can be expressed in Amazon States Language (ASL), a JSON dialect that Step Functions reads. Below is a minimal, runnable ASL snippet that shows the three states plus a retry policy for the LLM call.
{
"Comment": "Plan‑Act‑Observe loop using Claude + external tool",
"StartAt": "GeneratePlan",
"States": {
"GeneratePlan": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:bedrock:invokeModel",
"Parameters": {
"ModelId": "anthropic.claude-v2",
"Body.$": "$.prompt"
},
"Retry": [
{
"ErrorEquals": ["States.TaskFailed"],
"IntervalSeconds": 2,
"BackoffRate": 2,
"MaxAttempts": 3
}
],
"Next": "CallTool"
},
"CallTool": {
"Type": "Task",
"Resource": "arn:aws:states:::apigateway:invoke",
"Parameters": {
"ApiEndpoint": "https://abcde.execute-api.us-east-1.amazonaws.com/prod/run",
"Method": "POST",
"Headers": {
"Content-Type": "application/json"
},
"Body.$": "$.plan.tool_input"
},
"Catch": [
{
"ErrorEquals": ["States.ALL"],
"ResultPath": "$.toolError",
"Next": "FailLoop"
}
],
"Next": "ObserveResult"
},
"ObserveResult": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:bedrock:invokeModel",
"Parameters": {
"ModelId": "anthropic.claude-v2",
"Body.$": "States.Format('Observe: {}', $.toolResult)"
},
"Next": "ShouldContinue"
},
"ShouldContinue": {
"Type": "Choice",
"Choices": [
{
"Variable": "$.observation.done",
"BooleanEquals": false,
"Next": "GeneratePlan"
}
],
"Default": "Success"
},
"Success": {
"Type": "Succeed"
},
"FailLoop": {
"Type": "Fail",
"Cause": "Tool invocation failed"
}
}
}
Tip – The
Retryblock protects the LLM call from transient network hiccups, but without a proper timeout you could loop forever on a permanently failing tool.
CDK Boilerplate for the State Machine
Below is a TypeScript CDK construct that creates the same workflow. The comments walk a beginner through each line.
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as sfn from 'aws-cdk-lib/aws-stepfunctions';
import * as tasks from 'aws-cdk-lib/aws-stepfunctions-tasks';
import { BedrockClient } from '@aws-sdk/client-bedrock';
import { SfnClient } from '@aws-sdk/client-sfn';
// A tiny helper to keep the ASL JSON readable
const loopDefinition: sfn.StateMachineFragment = new sfn.Pass(this, 'Start')
// First state: ask Claude for a plan
.next(new tasks.CallAwsService(this, 'GeneratePlan', {
service: 'bedrock',
action: 'invokeModel',
parameters: {
ModelId: 'anthropic.claude-v2',
Body: sfn.JsonPath.stringAt('$.prompt')
},
// retry policy matches the ASL example
retryOnServiceExceptions: true,
maxAttempts: 3,
backoffRate: 2,
interval: cdk.Duration.seconds(2)
}))
// Second state: call the external tool through API Gateway
.next(new tasks.CallApiGatewayRestApi(this, 'CallTool', {
api: myApi, // defined elsewhere, see next section
method: 'POST',
stageName: 'prod',
requestBody: sfn.TaskInput.fromJsonPathAt('$.plan.tool_input')
})
.addCatch(new sfn.Fail(this, 'FailLoop', {
cause: 'Tool invocation failed'
})))
// Third state: feed tool output back to Claude
.next(new tasks.CallAwsService(this, 'ObserveResult', {
service: 'bedrock',
action: 'invokeModel',
parameters: {
ModelId: 'anthropic.claude-v2',
Body: sfn.JsonPath.stringAt('$.toolResult')
}
}))
// Decision point: are we done?
.next(new sfn.Choice(this, 'ShouldContinue')
.when(sfn.Condition.booleanEquals('$.observation.done', false), loopDefinition) // loop
.otherwise(new sfn.Succeed(this, 'Success')));
export class AgentStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// Create the state machine from the fragment above
new sfn.StateMachine(this, 'PlanActObserveSM', {
definition: loopDefinition,
timeout: cdk.Duration.minutes(5) // prevents endless execution
});
}
}
Key takeaway – By treating each loop step as a separate state, you inherit Step Functions’ built‑in observability and error handling without writing any custom glue code.
Implementing Claude Calls with the Bedrock SDK Integration
Why use Bedrock directly? – Bedrock is AWS’s managed LLM gateway. It abstracts the vendor‑specific API (Claude, Titan, etc.) behind a single, IAM‑controlled endpoint. Using the @aws-sdk/client-bedrock package lets you call Claude without managing keys or HTTP signatures yourself.
How the integration works – The InvokeModelCommand expects a JSON payload where prompt is a string. The response contains bytes that you must decode into UTF‑8 text. Below is a minimal Lambda function that the Step Functions Task state can invoke.
import { InvokeModelCommand, BedrockClient } from '@aws-sdk/client-bedrock';
import { APIGatewayProxyHandler } from 'aws-lambda';
// Re‑use a single client across invocations (cold‑start friendly)
const bedrock = new BedrockClient({ region: 'us-east-1' });
export const handler: APIGatewayProxyHandler = async (event) => {
// Pull the prompt from the incoming event – Step Functions puts it under "input"
const prompt = event.body?.prompt ?? 'You are an AI assistant.';
// Build the Bedrock request
const command = new InvokeModelCommand({
modelId: 'anthropic.claude-v2', // Claude model identifier
body: Buffer.from(JSON.stringify({ prompt })), // Bedrock expects a Uint8Array
contentType: 'application/json',
accept: 'application/json'
});
try {
const response = await bedrock.send(command);
// Decode the model's bytes back into a string
const result = Buffer.from(response.body as Uint8Array).toString('utf-8');
return {
statusCode: 200,
body: JSON.stringify({ result })
};
} catch (err) {
console.error('Claude invocation failed', err);
return {
statusCode: 500,
body: JSON.stringify({ error: 'Invocation error' })
};
}
};
In plain English – This Lambda just shovels a text prompt to Claude and returns whatever Claude says, handling network errors in a single place.
Gotchas to watch
-
Payload size – Both the input (
prompt) and the output (result) must stay under 256 KB because Step Functions limits each state’s input and output to that size. If you need larger data, store it in S3 and pass a reference. - Standard vs. Express – The Standard workflow keeps a full execution history (up to 25 000 events). A long‑running agent may hit that ceiling, so watch the history size in CloudWatch.
Calling an External Tool via API Gateway Integration
Why expose the tool through API Gateway? – API Gateway gives you a HTTP endpoint that can sit in front of any compute target (Lambda, Fargate, ECS, or even an external SaaS). It also provides request validation, throttling, and CORS handling out of the box.
How to wire it – The Step Functions Task state can use the built‑in apigateway:invoke integration, which removes the need for a separate Lambda wrapper. The following CDK snippet creates a mock “code‑execution” service and gives it a public REST endpoint.
import * as apigw from 'aws-cdk-lib/aws-apigateway';
import * as lambda from 'aws-cdk-lib/aws-lambda';
// Simple Lambda that pretends to run code and returns a result
const runner = new lambda.Function(this, 'CodeRunner', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'index.handler',
code: lambda.Code.fromInline(`
exports.handler = async (event) => {
const { code } = JSON.parse(event.body);
// In a real system you would sandbox this!
const result = eval(code); // ⚠️ unsafe, only for demo
return { statusCode: 200, body: JSON.stringify({ result }) };
};
`)
});
// API Gateway front‑end
const api = new apigw.RestApi(this, 'ToolApi', {
deployOptions: { stageName: 'prod' },
// CORS misconfiguration is a common support ticket – enable it here
defaultCorsPreflightOptions: { allowOrigins: apigw.Cors.ALL_ORIGINS }
});
const run = api.root.addResource('run');
run.addMethod('POST', new apigw.LambdaIntegration(runner), {
// 29‑second integration timeout cannot be increased – keep Lambda fast
integration: { timeout: cdk.Duration.seconds(29) }
});
Tip – Remember that API Gateway’s integration timeout is 29 seconds. If the external tool might run longer, break the work into smaller chunks or use an asynchronous pattern (e.g., SQS + Lambda).
Gotchas to watch
- JWT authorizers – The REST API (v1) does not support JWT authorizers natively. If you need token‑based auth, you must use a Lambda authorizer or switch to HTTP API (v2).
- Throttling – Limits are applied per AWS account, not per API. A busy unrelated API can starve this one, so consider request‑level throttling or a dedicated account for critical agents.
Observability, Retries, and Common Pitfalls
Why observability matters – An AI agent may loop many times before finishing. Without clear logs, you can’t tell whether it’s stuck, failing, or making progress. Step Functions already emits execution history, but you can enrich it with CloudWatch Logs, X‑Ray tracing, and custom metrics.
How to add logging – In CDK you can attach a LogGroup to the state machine and set a loggingConfiguration.
import * as logs from 'aws-cdk-lib/aws-logs';
// Create a dedicated log group with a 30‑day retention policy
const smLogGroup = new logs.LogGroup(this, 'AgentLogs', {
retention: logs.RetentionDays.THIRTY,
removalPolicy: cdk.RemovalPolicy.DESTROY
});
new sfn.StateMachine(this, 'PlanActObserveSM', {
definition: loopDefinition,
timeout: cdk.Duration.minutes(5),
logs: {
destination: smLogGroup,
level: sfn.LogLevel.ALL, // captures each state transition
includeExecutionData: true // puts input/output into the logs
}
});
In plain English – This configuration writes every state entry and exit to CloudWatch, so you can later replay the exact sequence that led to a failure.
The silent‑retry gotcha
Step Functions will automatically retry a state if you configure a Retry block. If you forget to set a TimeoutSeconds or a Catch on the tool‑call state, a permanently failing external service will cause the workflow to bounce forever, consuming resources and inflating your bill.
Safe pattern
- Timeout – Set a hard limit on each task (e.g., 30 seconds for the tool call).
-
Catch – Capture any error and move to a
Failstate that records the problem. -
Back‑off – Use an exponential back‑off strategy (
IntervalSeconds,BackoffRate) to avoid hammering a flaky service.
new tasks.CallApiGatewayRestApi(this, 'CallTool', {
// …parameters omitted for brevity
timeout: cdk.Duration.seconds(20), // ensures we don’t wait forever
})
.addCatch(new sfn.Fail(this, 'ToolFailed', {
cause: 'Tool did not respond in time',
error: 'Timeout'
}), {
errors: ['States.TaskFailed'],
resultPath: '$.toolError'
});
Other known limits
| Service | Limitation | Practical impact |
|---|---|---|
| Step Functions | 256 KB input/output per state | Large model responses must be stored elsewhere |
| Step Functions | 25 000 events per execution (Standard) | Very long loops can truncate history |
| Step Functions | Distributed Map → 10 000 concurrent child executions | Parallel tool calls need throttling |
| API Gateway | 29 s integration timeout | Long‑running tool must be asynchronous |
| API Gateway | CORS misconfiguration → “Missing Access‑Control‑Allow‑Origin” errors | Always set defaultCorsPreflightOptions for public APIs |
Tip – Keep the loop short (few iterations) or store intermediate data in DynamoDB/S3; otherwise you’ll bump into the event‑history or payload limits.
The Takeaway
Key points to remember
- A Plan‑Act‑Observe loop can be expressed as a deterministic state machine; Step Functions gives you that machine for free.
- Use Bedrock’s
InvokeModeltask to talk to Claude; keep payloads under 256 KB or offload to S3. - Expose external tools via API Gateway; respect the 29‑second timeout and configure CORS early.
- Add explicit
Retry,Timeout, andCatchblocks; otherwise the workflow may retry forever on a broken tool. - Leverage CloudWatch logging and the built‑in execution history to debug loops; watch the 25 000‑event limit for very long agents.
- Remember service‑specific limits (Step Functions input size, API Gateway throttling) when you scale from a prototype to production.
Next Steps
-
Deploy the example – Copy the CDK stack into a new project, run
cdk deploy, and trigger the state machine from the console. - Persist large data – Replace direct prompt/result passing with S3 object references once you exceed the 256 KB limit.
- Add async tool calls – Use an SQS queue + Lambda pattern for tools that need more than 29 seconds.
- Instrument with X‑Ray – Enable X‑Ray tracing on the state machine to get a visual trace of each iteration.
By treating AI agents as orchestrated state machines, you trade a heap of custom glue code for a platform that already handles retries, observability, and scaling. The result is a cleaner, more reliable agent that you can iterate on without worrying about underlying infrastructure. Happy building!
Transparency notice
This article was written with the help of an AI system — Groq (GPT OSS 120B).
Published: 2026-09-03 · Primary focus: StepFunctions
All code blocks are intended to be correct and runnable, but please verify them
against the official docs for the tools mentioned before using in production.Find an error? Drop a comment — corrections are always welcome.
Top comments (0)