Orchestrate AI Pipelines with Step Functions

Build a visual Step Functions workflow that classifies support tickets with Bedrock AI.

Introduction

30 Second Summary

Reading, categorizing, and routing every customer support ticket takes time. That process works at small scale, but it breaks down when hundreds of tickets arrive every hour.

In this project, you will build a visual AI pipeline using AWS Step Functions and Amazon Bedrock. The pipeline classifies customer support tickets with AI and routes each one to the right destination based on the model's confidence.

What You'll Build

You feed a support ticket into your pipeline and watch every stage light up on the Step Functions execution graph in real time.

By the end of this project, you'll have:

  • A working state machine that calls Amazon Bedrock's Nova Lite model directly to classify support tickets, visible as an animated execution graph in the AWS Console.
  • Conditional branching that routes tickets to different destinations based on the AI's confidence score. High-confidence tickets go to S3 storage, and low-confidence tickets trigger a Lambda escalation function.
  • Production-grade error handling with retry logic and fallback routing you can trigger on demand and watch recover in the execution graph.
  • Secret Mission: Chain a second AI call that generates a suggested customer response before storing the result.

Do I need an AWS account?

Yes, you need an AWS account to complete this project. Step 1 walks you through setting one up if you don't have one yet.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up Your AWS Environment and Create the S3 Bucket

Before your AI pipeline can classify support tickets, it needs a place to store the results. Every stage of the pipeline you are about to build feeds into the next, and the final output lands in a storage bucket you control.

In this step, you will sign into AWS, create an S3 bucket for storing pipeline output, and upload a sample support ticket that your AI model will classify in the next step.

In this step, get ready to:
  • Sign in to AWS and confirm your region is set to US East (N. Virginia).
  • Create an S3 bucket to store your pipeline results.
  • Upload a sample support ticket to the bucket.
Sign in to your AWS account

All of the work in this project happens inside the AWS Management Console. You need an AWS account to get started.

✔️ I already have an AWS account

  • Sign in with your IAM user or root account credentials.
  • You should see the AWS Management Console dashboard after signing in.

ⓧ I need to create an AWS account

  • Click Create an AWS Account on the sign-in page.
  • Enter your email address, choose a password, and pick an account name.
  • Complete the contact information, billing details (a credit card is required, but you will stay within the free tier for most of this project), and identity verification steps.
  • Select the Basic (Free) support plan.
  • After your account is created, sign in to the AWS Management Console.

How long does account creation take?

Most accounts are ready within a few minutes. Occasionally, AWS takes up to 24 hours to fully activate a new account. If you see an error when trying to access a service, wait a few minutes and try again.

You should now see the AWS Management Console dashboard.

  • Look at the top-right corner of the console. You should see a region name next to the account menu.
  • Click the region name to open the region selector dropdown.
  • Select US East (N. Virginia) if it is not already selected. This sets your region to us-east-1.

Confirm the top-right corner now displays N. Virginia.

Why us-east-1?

Amazon Bedrock and Step Functions both have full feature support in us-east-1. Using a single region for all resources keeps permissions simple and avoids cross-region latency.

Don't see a region selector?

If the top bar looks different, make sure you are on the main AWS Management Console page, not a specific service page. The region selector is always in the navigation bar at the top of the console.

help me find the region selector in the AWS Console

Create your S3 bucket

Your pipeline will write classification results to an S3 bucket. S3 buckets are cloud storage containers that hold files (called objects) and make them accessible to other AWS services.

  • In the AWS Console search bar at the top, type S3 and select S3 from the results.
  • In the left navigation pane, click General purpose buckets.
  • Click Create bucket.

The Create bucket page opens. Fill in the details:

  • For Bucket name, enter a unique name using the pattern ai-pipeline-results- followed by your initials and today's date. For example: ai-pipeline-results-jd-20260815.
  • Leave all other settings at their defaults. Block all public access should be checked and default encryption should be enabled.
  • Scroll to the bottom and click Create bucket.

You should see your new bucket appear in the General purpose buckets list.

Why does my bucket name need to be globally unique?

Every S3 bucket name must be unique across all AWS accounts worldwide. That is why you add your initials and the date to the name. Bucket names must also be lowercase, between 3 and 63 characters, and contain only letters, numbers, and hyphens.

Seeing a bucket name error?

If AWS says the bucket name is already taken, try adding extra characters to make it unique. For example, add a random number after the date.

If you see a message about invalid characters, make sure your name uses only lowercase letters, numbers, and hyphens. No spaces or uppercase letters are allowed.

help me fix my S3 bucket name error

Upload a sample support ticket

Your AI pipeline needs data to process. You will create a sample support ticket as a JSON file and upload it to your bucket. This ticket is what the Step Functions state machine will classify in the next step.

  • Open any plain text editor on your computer (such as Notepad on Windows, or TextEdit on macOS set to plain text mode via Format then Make Plain Text).
  • Paste the following JSON into the editor:
{
  "ticketId": "TKT-001",
  "subject": "Unable to access account dashboard",
  "description": "I have been trying to log into my account for the past two hours. The page loads but then shows a blank white screen. I have tried clearing my browser cache and using a different browser but the issue persists. This is urgent as I need to submit a report by end of day.",
  "priority": "high"
}

What does this ticket contain?

  • `ticketId` is a unique identifier for the support request.
  • `subject` is a short summary of the issue the customer is reporting.
  • `description` is the full customer message with context and urgency details. This is the field the AI model will analyze most closely.
  • `priority` indicates how urgent the customer considers the issue.
  • Save the file as ticket.json on your Desktop.

Make sure the file extension is .json

On Windows, Notepad may save the file as ticket.json.txt by default. To avoid this, change the Save as type dropdown to All Files before saving. On macOS, make sure TextEdit is in plain text mode before saving.

  • Switch back to the S3 console in your browser.
  • Click the name of the bucket you just created to open it.
  • On the Objects tab, click Upload.
  • Under Files and folders, click Add files.
  • Select the ticket.json file from your Desktop, then click Open.
  • Click Upload.

Before you check the result, take a guess: what do you expect to see on the Objects tab when you go back to your bucket?

  • After the upload completes, click Close to return to your bucket.

You should see ticket.json listed on the Objects tab of your bucket. Your storage is ready for the pipeline.

Don't see ticket.json in the bucket?

Make sure you clicked Upload (the orange button) after adding the file. If the upload status page says the upload succeeded but the file does not appear, click your bucket name in the breadcrumb navigation at the top to refresh the object list.

If the file appears as ticket.json.txt instead of ticket.json, delete it and re-save the file from your text editor with the correct .json extension.

help me upload a file to my S3 bucket

Your AWS environment is ready and your pipeline has its first piece of data. Next up, you will build a Step Functions state machine that calls Amazon Bedrock to classify this ticket using AI.

Build Your First State Machine with a Bedrock AI Call

Your S3 bucket is set up and loaded with a sample support ticket. Now it is time to build the core of your pipeline.

In this step, you will create a Step Functions state machine that calls Amazon Bedrock directly to classify a support ticket using AI. The best part: Step Functions has an optimized integration with Bedrock, so you can call the AI model straight from your state machine without writing any Lambda code.

In this step, get ready to:
  • Create a Standard state machine with a Bedrock InvokeModel task using the Code editor.
  • Run a test execution and watch the AI classify a support ticket in real time.
  • Run a second execution with a different ticket to confirm the model returns different classifications.
Create your state machine in the Step Functions console

Amazon States Language (ASL) is the JSON-based language that defines how a state machine behaves. You will write a short ASL definition that tells Step Functions to call Bedrock's Amazon Nova Lite model and classify whatever ticket you feed in.

  • Open the Step Functions console by searching for Step Functions in the AWS search bar and selecting it.
  • Click Create state machine.
  • Select Create from blank.
  • Name your state machine TicketClassifierPipeline, then click Continue.

Name your state machine before you save

You cannot rename a state machine after you create it. Make sure the name is correct before continuing.

  • Switch to Code mode by clicking the Code button at the top of Workflow Studio.
  • Select all the boilerplate code in the editor and delete it.
  • Paste the following ASL definition into the Code editor:
{
  "Comment": "AI Support Ticket Classifier",
  "StartAt": "ClassifyTicket",
  "States": {
    "ClassifyTicket": {
      "Type": "Task",
      "Resource": "arn:aws:states:::bedrock:invokeModel",
      "Parameters": {
        "ModelId": "amazon.nova-lite-v1:0",
        "ContentType": "application/json",
        "Accept": "application/json",
        "Body": {
          "schemaVersion": "messages-v1",
          "system": [
            {
              "text": "You are a support ticket classifier. Respond with ONLY a valid JSON object with two fields: category (one of: billing, technical, account, general) and confidence_score (a number between 0 and 1). No other text."
            }
          ]
        }
      },
      "End": true
    }
  }
}

What does this definition do?

  • ClassifyTicket is a Task state that calls Bedrock's optimized integration using the resource ARN arn:aws:states:::bedrock:invokeModel.
  • ModelId is set to amazon.nova-lite-v1:0, Amazon's cheapest text model. It is enabled by default in your account with no extra setup needed.
  • The system array gives the model its instructions: classify tickets and respond only with structured JSON containing category and confidence_score.
  • Parameters (not Arguments) is used because this state machine uses the JSONPath query language, which is the default.

This definition is missing the messages array that sends the actual ticket text to the model. You will add that next so the ticket input flows in dynamically.

Add the dynamic ticket input to the Bedrock call

The state machine needs to take whatever ticket you pass as execution input and inject it into the prompt. Step Functions provides an intrinsic function called States.Format that builds a string from placeholders and input values at runtime.

  • In the Code editor, find the closing bracket of the system array (the ] after the system text object).
  • Add a comma after the ], then paste the following messages and inferenceConfig blocks inside the Body object, right after the system array:
"messages": [
  {
    "role": "user",
    "content": [
      {
        "text.$": "States.Format('Classify this support ticket. Subject: {} Description: {} Priority: {}', $.subject, $.description, $.priority)"
      }
    ]
  }
],
"inferenceConfig": {
  "maxTokens": 256
}

What does this code do?

  • The messages array sends a user message to the model containing the ticket details.
  • The text.$ key (note the .$ suffix) tells Step Functions this value is dynamic. States.Format injects the subject, description, and priority fields from your execution input into the prompt string.
  • inferenceConfig sets maxTokens to 256, which is more than enough for a short JSON classification response.

Check the graph visualization panel on the right. You should see a single ClassifyTicket state rendered as a box.

✔️ Awesome, I've got everything!

Great. Double-check that the graph shows a single ClassifyTicket state box before continuing.

ⓧ I'd like to double check the full code

Here is the complete ASL definition. Make sure your Code editor matches this exactly:

{
  "Comment": "AI Support Ticket Classifier",
  "StartAt": "ClassifyTicket",
  "States": {
    "ClassifyTicket": {
      "Type": "Task",
      "Resource": "arn:aws:states:::bedrock:invokeModel",
      "Parameters": {
        "ModelId": "amazon.nova-lite-v1:0",
        "ContentType": "application/json",
        "Accept": "application/json",
        "Body": {
          "schemaVersion": "messages-v1",
          "system": [
            {
              "text": "You are a support ticket classifier. Respond with ONLY a valid JSON object with two fields: category (one of: billing, technical, account, general) and confidence_score (a number between 0 and 1). No other text."
            }
          ],
          "messages": [
            {
              "role": "user",
              "content": [
                {
                  "text.$": "States.Format('Classify this support ticket. Subject: {} Description: {} Priority: {}', $.subject, $.description, $.priority)"
                }
              ]
            }
          ],
          "inferenceConfig": {
            "maxTokens": 256
          }
        }
      },
      "End": true
    }
  }
}
  • Click Config mode at the top to open the state machine settings.
  • Confirm the State machine name is TicketClassifierPipeline.
  • Under Type, confirm Standard is selected (this is the default).
  • Under Permissions, keep the default Create a new role selection. Step Functions will auto-generate an IAM role with the permissions needed to call Bedrock.
  • Click Create.
  • In the Confirm role creation dialog, click Confirm.

Why no manual IAM setup?

When you create a state machine through the console, Step Functions automatically generates an execution role with least-privilege permissions. Because your definition references arn:aws:states:::bedrock:invokeModel, the console adds bedrock:InvokeModel permission to the role for you.

You should see a notification that your state machine and a new IAM role have been created.

Run your first test execution

Your state machine exists, but it has never run. Time to feed it a support ticket and watch the AI classify it in real time.

  • Click Start execution.
  • In the Input field, replace the default JSON with the following sample support ticket:
{
  "ticketId": "TKT-001",
  "subject": "Cannot access my account after password reset",
  "description": "I reset my password yesterday but now I get an error saying my credentials are invalid. I have tried clearing my browser cache and using a different browser but nothing works.",
  "priority": "high"
}
  • Click Start execution.

Before you look at the results, take a moment to predict: do you think the model will classify this as a billing, technical, account, or general issue?

Watch the execution graph animate. The ClassifyTicket state should turn green, meaning Bedrock returned a successful response.

  • Click on the ClassifyTicket state in the graph to select it.
  • In the Step details panel that appears, click the Output tab to inspect the Bedrock response.

You should see a JSON response from Bedrock. Inside the Body field of the output, look for the model's text content. It contains a JSON object with category (likely account) and confidence_score (a number between 0 and 1).

Execution failed or showing an error?

  • Check that you are in the us-east-1 region. Nova Lite availability varies by region.
  • Verify your ASL definition has no syntax errors. Look for missing commas, mismatched brackets, or a missing .$ suffix on the text key.
  • Make sure the input JSON includes all four fields: ticketId, subject, description, and priority.

Still stuck? Help me debug my Step Functions Bedrock execution error.

Run a second execution with a different ticket

One successful execution is great. But does the model actually give different classifications for different tickets? Run a second test to find out.

  • Click Start execution again from the state machine page (use the breadcrumb link at the top to navigate back if needed).
  • Replace the input with this different support ticket:
{
  "ticketId": "TKT-002",
  "subject": "Unexpected charge on my invoice",
  "description": "I noticed a $49.99 charge on my latest invoice that I do not recognize. I did not upgrade my plan or purchase any add-ons. Please explain this charge or issue a refund.",
  "priority": "medium"
}
  • Click Start execution.
  • Once the execution completes (green ClassifyTicket state), click the state and check the Output tab.

This time the category should be billing instead of account. The model reads the ticket content and classifies accordingly. Two different tickets, two different AI classifications, all orchestrated by your state machine.

What about the confidence score?

The confidence_score reflects how certain the model is about its classification. A clear billing complaint typically scores above 0.8. An ambiguous ticket that spans multiple categories might score below 0.7. In the next step, you will use this score to make the pipeline branch automatically.

Your state machine is calling an AI model and getting back structured classifications. Next up, you will add a Choice state that reads the confidence score and routes tickets down different paths automatically.

Add Conditional Branching with a Choice State

In the last step, you built a state machine that calls Amazon Bedrock and classifies a support ticket. The AI responds with a category and confidence score every time. But right now, every ticket follows the exact same path regardless of how confident the model is.

Real orchestration means making decisions automatically. In this step, you will add a Choice state that reads the AI's confidence score and branches the workflow into two paths: high-confidence tickets get auto-resolved, and low-confidence tickets get escalated.

In this step, get ready to:
  • Add a Pass state that parses the Bedrock response into a structure the Choice state can evaluate.
  • Add a Choice state that branches on the AI's confidence score.
  • Test both the high-confidence and low-confidence paths with different ticket inputs.
Add a ParseResponse state to extract the classification

The Bedrock InvokeModel response returns the AI's answer as a JSON string buried inside the response body. The Choice state needs to read confidence_score as an actual number, not a string. A Pass state with an intrinsic function can parse that string into a real JSON object.

  • Return to the Step Functions console and open your state machine.
  • Click Edit in the top right, then switch to the Code editor view.
  • Find the ClassifyTicket state in your ASL definition. Change its "End": true to "Next": "ParseResponse" so the workflow continues to the new state instead of stopping.

Here is the line-edit for that change. Find this line inside ClassifyTicket:

"End": true
  • Replace it with:
"Next": "ParseResponse"
  • Now add the ParseResponse state directly after the closing brace of the ClassifyTicket state. Paste this new state into the States object:
"ParseResponse": {
  "Type": "Pass",
  "Parameters": {
    "classification.$": "States.StringToJson($.Body.output.message.content[0].text)"
  },
  "End": true
}

What does this state do?

The Bedrock model returns its answer as a text string inside $.Body.output.message.content[0].text. That string contains JSON like {"category": "billing", "confidence_score": 0.92}, but the Choice state cannot evaluate a raw string as a number.

The States.StringToJson intrinsic function parses that string into a real JSON object. The result lands under $.classification, so the next state can read $.classification.confidence_score as an actual number.

Intrinsic functions are a JSONPath-only feature in Step Functions. They let you do basic data transformations inside the state machine without writing Lambda code.

  • Click Save, then confirm the IAM role update if prompted.
  • Click Start execution and paste the same sample ticket JSON you used in Step 2 as the input.
  • Click Start execution again to run it.

The execution graph should show both ClassifyTicket and ParseResponse in green. Click on the ParseResponse state in the graph and check its Output tab. You should see a clean JSON object with classification.category and classification.confidence_score as a proper number.

Seeing an error on ParseResponse?

  • Make sure the key ends with .$ ("classification.$"). Without the .$ suffix, Step Functions treats the value as a literal string instead of evaluating the intrinsic function.
  • Check that ClassifyTicket now says "Next": "ParseResponse" and not "End": true. A missing transition means the workflow stops before it reaches the new state.
  • Verify there is a comma separating the ClassifyTicket state from the ParseResponse state in the JSON. Missing commas between states are the most common syntax error.

Still stuck? Help me debug a ParseResponse state error in my Step Functions state machine.

Add the Choice state and placeholder branches

Now that the confidence score is a real number at a known path, you can add the branching logic. A Choice state evaluates rules against the input and routes the workflow to different states based on the result.

  • In the Code editor, find the ParseResponse state. Change its "End": true to "Next": "RouteByConfidence".

Here is the line-edit. Find this line inside ParseResponse:

"End": true
  • Replace it with:
"Next": "RouteByConfidence"
  • Now add three new states after ParseResponse in the States object. Paste the following:
"RouteByConfidence": {
  "Type": "Choice",
  "Choices": [
    {
      "Variable": "$.classification.confidence_score",
      "NumericGreaterThanEquals": 0.8,
      "Next": "AutoResolve"
    }
  ],
  "Default": "Escalate"
},
"AutoResolve": {
  "Type": "Pass",
  "Result": {
    "status": "auto-resolved"
  },
  "End": true
},
"Escalate": {
  "Type": "Pass",
  "Result": {
    "status": "escalated"
  },
  "End": true
}

What does this code do?

  • RouteByConfidence is a Choice state. It reads $.classification.confidence_score and checks if the value is greater than or equal to 0.8. If it is, the workflow transitions to AutoResolve.
  • If none of the rules match (confidence below 0.8), the Default field sends the workflow to Escalate instead.
  • AutoResolve and Escalate are placeholder Pass states for now. They return a status string and end the workflow. In the next step, you will replace these with real S3 and Lambda integrations.

The NumericGreaterThanEquals comparison operator is one of many that Choice states support. It works because States.StringToJson already converted the confidence score from a string to a real number in the ParseResponse state.

  • Click Save and confirm the IAM role update if prompted.

Before you test, check the visual graph in the console. You should see the workflow now branches after RouteByConfidence into two separate paths leading to AutoResolve and Escalate.

Graph not showing the branch?

  • Check that RouteByConfidence has "Type": "Choice" (capital C). A typo here makes Step Functions treat it as an unknown type.
  • Make sure AutoResolve and Escalate are defined inside the States object at the same level as the other states. Nesting them inside another state causes a validation error.
  • Verify there are commas between each state definition in the JSON.

Still stuck? Help me debug my Choice state in Step Functions.

Test both branches with different tickets

The branching is wired up, but you need to prove both paths actually work. You will run two executions with different ticket inputs: one that should trigger high confidence, and one that should trigger low confidence.

  • Click Start execution. Paste a clear, straightforward support ticket as input to trigger a high-confidence classification:
{
  "ticketId": "TK-1001",
  "subject": "I was charged twice for my subscription",
  "description": "I checked my credit card statement and see two charges of $29.99 from your service on the same day. Please refund the duplicate charge.",
  "priority": "high"
}
  • Click Start execution to run it.

Before you check the result, what path do you think the workflow will take? The ticket is a clear billing issue, so the model should classify it with high confidence.

Click on the RouteByConfidence state in the execution graph. In the Input tab, you should see the parsed confidence_score as a number at or above 0.8. The graph should show the AutoResolve path lit up in green.

  • Now run a second execution. This time, paste an ambiguous ticket that should produce lower confidence:
{
  "ticketId": "TK-1002",
  "subject": "Multiple issues with my account",
  "description": "I cannot log in sometimes, my billing looks wrong, and I think someone else accessed my account. Also the app crashes when I open settings. Not sure what is going on.",
  "priority": "medium"
}
  • Click Start execution to run it.

This ticket mentions billing, technical issues, account security, and app crashes all at once. The model should struggle to assign a single category confidently.

Check the execution graph. The Escalate path should now be lit up in green, confirming the workflow routed the low-confidence ticket to escalation.

What if both tickets take the same path?

AI models are not deterministic. If the second ticket still scores above 0.8, try making the description even more vague or mixing in more unrelated topics. The goal is to see BOTH paths light up across your two test runs.

You can also click on the ParseResponse output to see the exact confidence_score the model returned. This helps you understand where the 0.8 threshold falls relative to the model's actual outputs.

Both executions failing?

  • Click on the failed state in the execution graph and check the Error tab for the exact error message.
  • If RouteByConfidence fails with a type error, the ParseResponse state may not be parsing correctly. Check that the States.StringToJson path matches the exact Bedrock response structure.
  • If the execution stops at ClassifyTicket, make sure "Next": "ParseResponse" replaced "End": true and that you saved the state machine.

Still stuck? Help me debug my branching test executions in Step Functions.

✔️ Awesome, I've got everything!

Great. Make sure you have saved your state machine definition.

ⓧ I'd like to double check the full code

Your full ASL definition should now contain five states in this order: ClassifyTicket, ParseResponse, RouteByConfidence, AutoResolve, Escalate. Scroll through your Code editor and confirm each one:

  • ClassifyTicket: a Task state with "Resource": "arn:aws:states:::bedrock:invokeModel" and "Next": "ParseResponse" (not "End": true).
  • ParseResponse: a Pass state with Parameters containing "classification.$": "States.StringToJson(...)" and "Next": "RouteByConfidence".
  • RouteByConfidence: a Choice state with NumericGreaterThanEquals 0.8 pointing to AutoResolve, and Default pointing to Escalate.
  • AutoResolve: a Pass state with "End": true.
  • Escalate: a Pass state with "End": true.

Your state machine can now classify a ticket AND decide what to do with it. The placeholder Pass states prove the branching works visually. Next up, you will replace those placeholders with real backends: S3 for auto-resolved tickets and Lambda for escalations.

Wire Up the Real Backends (S3 and Lambda)

In the last step, you proved your pipeline can branch. High-confidence tickets route one way, low-confidence tickets route another.

The problem is that both paths end in placeholder Pass states that do nothing useful. Real orchestration means connecting to real services. In this step, you will replace those placeholders with an S3 PutObject task (for auto-resolved tickets) and a Lambda function (for escalated tickets).

In this step, get ready to:
  • Replace the AutoResolve placeholder with an S3 PutObject task that writes classification results to your bucket.
  • Create a Lambda function that logs escalation payloads and wire it into the Escalate path.
  • Run end-to-end tests for both paths and verify real data lands in S3 and CloudWatch.
Replace the AutoResolve placeholder with an S3 PutObject task

Your AutoResolve state is currently a Pass state that accepts input and does nothing with it. You will replace it with a Task state that calls the S3 PutObject API directly from Step Functions, writing the classification result as a JSON file to your bucket.

  • Return to the Step Functions console and open your state machine.
  • Click Edit to enter edit mode.
  • Switch to the code editor view (click Code if you are in Workflow Studio).
  • Find the AutoResolve state in your ASL definition. It currently looks like this:
"AutoResolve": {
  "Type": "Pass",
  "End": true
}
  • Replace the entire AutoResolve state with the following S3 PutObject task:
"AutoResolve": {
  "Type": "Task",
  "Resource": "arn:aws:states:::aws-sdk:s3:putObject",
  "Parameters": {
    "Bucket": "[[S3_BUCKET="your-bucket-name e.g. ai-pipeline-results-jd-20260815"]]",
    "Key.$": "States.Format('resolved/{}.json', $.ticketId)",
    "Body.$": "States.JsonToString($)",
    "ContentType": "application/json"
  },
  "End": true
}

What does this code do?

This Task state calls the S3 PutObject API directly from Step Functions using the AWS SDK integration. No Lambda function is needed for this.

  • Resource uses the ARN arn:aws:states:::aws-sdk:s3:putObject to call S3 directly.
  • Key.$ uses the States.Format intrinsic function to build a dynamic file path like resolved/TKT-001.json from the ticket ID.
  • Body.$ converts the entire state input (the classification result) into a JSON string so S3 can store it as a file.

Seeing a validation error when you save?

Make sure you replaced your actual bucket name in the Bucket field. The bucket name must exactly match the one you created in Step 1.

Also check that the .$ suffix is present on Key.$ and Body.$. Without it, Step Functions treats the value as a literal string instead of a JSONPath expression.

Help me fix an S3 PutObject validation error in my Step Functions state machine.

Create the Lambda escalation function and wire it in

The Escalate path needs to call a Lambda function that logs the escalation details. You will create this function first, then update the state machine to point to it.

  • Open the Lambda console by searching for Lambda in the AWS search bar.
  • Click Create function.
  • Select Author from scratch.
  • Enter EscalationHandler as the function name.
  • Choose Python 3.13 (or the latest available Python runtime) from the Runtime dropdown.
  • Keep all other defaults and click Create function.

After the function is created, you will see the code editor. Replace the default code with the escalation handler.

  • In the Code source editor, select all the existing code and replace it with the following:
import json

def lambda_handler(event, context):
    # Log the full escalation payload for review
    print("ESCALATION RECEIVED:")
    print(json.dumps(event, indent=2))

    # Return a confirmation message
    return {
        "status": "escalated",
        "message": "Ticket has been escalated for manual review",
        "ticketId": event.get("ticketId", "unknown")
    }

What does this function do?

This is a simple handler that receives the escalation payload (ticket details and classification), logs it to CloudWatch, and returns a confirmation message. In a real system, this function would create a support ticket, send a notification, or page an on-call engineer.

  • Click Deploy to save and deploy your Lambda function.
  • After deployment, copy the function's ARN from the top of the page. It looks like arn:aws:lambda:us-east-1:123456789012:function:EscalationHandler.

You should see a green banner confirming the function was updated.

Don't see the Deploy button?

Make sure you are in the Code tab (not Configuration or Test). The Deploy button appears directly above the code editor.

Help me find the Deploy button in the Lambda console.

Now return to your state machine and wire the Lambda function into the Escalate path.

  • Switch back to the Step Functions console tab where your state machine is open in edit mode.
  • Find the Escalate state in your ASL definition. It currently looks like this:
"Escalate": {
  "Type": "Pass",
  "End": true
}
  • Replace the entire Escalate state with the following Lambda Invoke task:
"Escalate": {
  "Type": "Task",
  "Resource": "arn:aws:states:::lambda:invoke",
  "Parameters": {
    "FunctionName": "arn:aws:lambda:us-east-1:[[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]]:function:EscalationHandler",
    "Payload.$": "$"
  },
  "End": true
}

What does this code do?

This Task state uses the optimized Lambda integration (arn:aws:states:::lambda:invoke) to call your EscalationHandler function. The optimized integration automatically parses the Lambda response from escaped JSON into a proper JSON object.

  • FunctionName points to the ARN of the Lambda function you just created.
  • Payload.$ passes the entire state input (ticket details and classification) as the Lambda event payload.

Why use the optimized Lambda integration instead of the SDK integration?

Step Functions offers two ways to call Lambda: the AWS SDK integration (arn:aws:states:::aws-sdk:lambda:invoke) and the optimized integration (arn:aws:states:::lambda:invoke). AWS recommends the optimized integration because it automatically parses the Lambda response payload from escaped JSON into a JSON object. This saves you an extra parsing step.

For S3 there is no optimized integration, so you used the AWS SDK integration. For Lambda, you get the optimized path.

  • Click Save to save your state machine changes.

The console may prompt you to update the IAM role. If it does, accept the changes to grant your state machine the new s3:PutObject and lambda:InvokeFunction permissions it needs.

What if the console doesn't auto-update the IAM role?

Step Functions auto-generates IAM policies when you create a state machine via the console. When you edit the definition and add new service integrations, it usually prompts you to update the role on save. If it does not, you can manually add s3:PutObject and lambda:InvokeFunction permissions to the execution role via the IAM console.

Run end-to-end tests for both paths

Your pipeline is now fully connected. Time to run real tickets through it and verify that data actually lands in S3 and CloudWatch.

  • Click Start execution on your state machine.
  • Paste the following high-confidence test ticket as the input:
{
  "ticketId": "TKT-101",
  "subject": "Password reset not working",
  "description": "I clicked the reset password link but never received the email. I have checked my spam folder.",
  "priority": "medium"
}
  • Click Start execution.

Before you check the result, think about what you expect to happen. Which path should this ticket take? What should you find in your S3 bucket?

Watch the execution graph animate. This is a clear, straightforward issue, so the AI model should classify it with high confidence. The execution should route through AutoResolve and show all states in green.

  • Open the S3 console in a new tab.
  • Navigate to your bucket and open the resolved/ folder.
  • You should see a file named TKT-101.json. Click on it and choose Download (or use Open) to verify it contains the ticket details and classification result.

Don't see a resolved/ folder in your bucket?

Check the execution output in the Step Functions console. Click on the AutoResolve state in the execution graph and inspect its input and output. If the state failed, the error message will tell you what went wrong.

Common causes: the bucket name in your ASL does not match your actual bucket name, or the IAM role is missing s3:PutObject permission.

Help me debug why my S3 PutObject state failed in Step Functions.

Now test the escalation path.

  • Return to the Step Functions console and click Start execution again.
  • Paste the following ambiguous test ticket:
{
  "ticketId": "TKT-102",
  "subject": "Multiple issues with account",
  "description": "My billing is wrong, I cannot access the dashboard, and I also want to upgrade my plan. Additionally, the mobile app crashes when I try to view invoices.",
  "priority": "high"
}
  • Click Start execution.

This ticket covers multiple unrelated problems. The AI model should return a lower confidence score, routing it to the Escalate path.

  • Watch the execution graph. The workflow should route through the Escalate state and complete in green.
  • Open the CloudWatch console by searching for CloudWatch in the AWS search bar.
  • Click Log groups in the left sidebar.
  • Find the log group for your Lambda function. It is named /aws/lambda/EscalationHandler.
  • Click on the latest log stream.
  • You should see the ESCALATION RECEIVED: log entry followed by the full ticket payload and classification details.

Escalate state failed or no CloudWatch logs?

Click on the Escalate state in the execution graph and check the error message. The most common cause is an IAM permission issue. Your state machine's execution role needs lambda:InvokeFunction permission for the EscalationHandler function.

If the state succeeded but you see no logs, make sure you are checking CloudWatch in the us-east-1 region.

Help me debug why my Lambda escalation state failed in Step Functions.

Your pipeline is now a real system. High-confidence tickets are automatically stored in S3, and low-confidence tickets trigger a Lambda function for manual review. Next up, you will make this pipeline production-grade by adding error handling that retries transient failures and catches unrecoverable ones.

Add Error Handling with Retry and Catch

Your pipeline routes tickets to S3 or Lambda depending on the AI's confidence. That works perfectly on the happy path.

In production, Bedrock calls can be throttled and Lambda functions can time out. Without error handling, a single transient failure kills the entire workflow. In this step, you will add Retry and Catch blocks that make your pipeline production-grade.

In this step, get ready to:
  • Add Retry blocks with exponential backoff to the Bedrock and Lambda tasks.
  • Add Catch blocks with Fail states for unrecoverable errors.
  • Deliberately trigger an error and watch the retry and fallback behavior in the execution graph.
Add Retry blocks to the Bedrock and Lambda tasks

A Retry block tells Step Functions to automatically re-attempt a failed task before giving up. You configure how many times to retry, how long to wait between attempts, and how quickly to increase that wait time.

  • Return to your state machine in the Step Functions console.
  • Click Edit to open the state machine in Workflow Studio.
  • Switch to Code mode so you can edit the ASL definition directly.
  • Find the ClassifyTicket state. Add the following Retry block inside it, just before the "Next": "ParseResponse" line:
"Retry": [
  {
    "ErrorEquals": ["States.ALL"],
    "IntervalSeconds": 2,
    "MaxAttempts": 3,
    "BackoffRate": 2
  }
],

What does this Retry block do?

  • ErrorEquals: States.ALL is a wildcard that matches any error. This means the Bedrock task retries on every type of failure, whether it is throttling, a timeout, or an unexpected service error.
  • IntervalSeconds: waits 2 seconds before the first retry.
  • MaxAttempts: tries up to 3 times before giving up.
  • BackoffRate: multiplies the wait time by 2 after each attempt. So the waits are 2s, then 4s, then 8s. This is exponential backoff, and it gives a struggling service time to recover.
  • Now find the Escalate state (the Lambda Invoke task). Add the following Retry block inside it, just before its "End": true line:
"Retry": [
  {
    "ErrorEquals": [
      "Lambda.ServiceException",
      "Lambda.AWSLambdaException",
      "Lambda.SdkClientException",
      "Lambda.TooManyRequestsException"
    ],
    "IntervalSeconds": 2,
    "MaxAttempts": 3,
    "BackoffRate": 2
  }
],

Why different error types for the Lambda task?

The Bedrock task uses States.ALL because any Bedrock error is worth retrying. The Lambda task lists four specific transient errors instead. These are errors where the Lambda service itself had a temporary problem, not errors in your function's code. A bug in your code would fail the same way every time, so retrying it would be pointless.

  • Glance at the graph visualization on the right side of the code editor. It should still render your full workflow without errors.

Graph visualization paused or showing errors?

  • Check for missing or extra commas in your JSON. A trailing comma after the last property in a JSON object is the most common cause.
  • Make sure the Retry block is inside the state's curly braces, not outside them.
  • Look for the inline error indicator in the code editor. It highlights the exact line with the problem.

Why is my Retry JSON breaking the graph?

Add Catch blocks and Fail states

Retry handles transient failures, but some errors are permanent. A Catch block kicks in after all retries are exhausted. It routes the execution to a fallback state instead of crashing the whole workflow.

  • In the ClassifyTicket state, add the following Catch block right after the Retry block you just added:
"Catch": [
  {
    "ErrorEquals": ["States.ALL"],
    "Next": "ClassificationFailed"
  }
],
  • In the Escalate state, add the following Catch block right after its Retry block:
"Catch": [
  {
    "ErrorEquals": ["States.ALL"],
    "Next": "EscalationFailed"
  }
],

What does the Catch block do?

After all retry attempts are exhausted, Catch checks the error against its ErrorEquals list. If it matches, execution transitions to the state named in Next instead of failing the entire workflow. The States.ALL wildcard catches any error that made it past the retries.

  • The Catch blocks reference two states that do not exist yet: ClassificationFailed and EscalationFailed. Add both Fail states inside the "States" object, after the Escalate state:
"ClassificationFailed": {
  "Type": "Fail",
  "Error": "ClassificationError",
  "Cause": "The Bedrock model failed to classify the support ticket after retries."
},
"EscalationFailed": {
  "Type": "Fail",
  "Error": "EscalationError",
  "Cause": "The Lambda escalation function failed after retries."
}

Why Fail states instead of Pass states?

A Fail state stops the execution and marks it as failed with a specific error code and human-readable cause. This is intentional. In a production system, a failed execution triggers CloudWatch alarms, shows up in monitoring dashboards, and tells your team exactly what went wrong. A Pass state would silently swallow the error.

  • Click Save in the toolbar to save your updated state machine.
  • Confirm the graph visualization renders your full workflow with no errors. You should see the new ClassificationFailed and EscalationFailed states appear as new branches in the graph.

Seeing a validation error when you save?

  • Double-check that ClassificationFailed and EscalationFailed are spelled exactly the same in the Catch block's Next field and in the state name. Case matters.
  • Make sure both Fail states are inside the top-level "States" object, not nested inside another state.
  • Check for missing commas between states. Every state except the last one in the "States" object needs a comma after its closing brace.

Help me fix my Catch and Fail state ASL definition.

✔️ Awesome, I've got everything!

Great. Double check you have saved your state machine and the graph visualization shows the two new Fail states branching off from ClassifyTicket and Escalate.

ⓧ I'd like to double check the full code

Your ASL definition should now contain all of the following states and blocks. Scroll through your code in the editor and confirm each one is present in order:

  • ClassifyTicket Task state with Resource set to arn:aws:states:::bedrock:invokeModel, a Retry block (States.ALL, IntervalSeconds 2, MaxAttempts 3, BackoffRate 2), and a Catch block routing to ClassificationFailed.
  • ParseResponse Pass state extracting the AI response fields.
  • RouteByConfidence Choice state branching on NumericGreaterThanEquals 0.8.
  • AutoResolve Task state using arn:aws:states:::aws-sdk:s3:putObject with key resolved/{ticketId}.json.
  • Escalate Task state invoking the Lambda function, with a Retry block (four Lambda-specific error types) and a Catch block routing to EscalationFailed.
  • ClassificationFailed Fail state with Error set to ClassificationError.
  • EscalationFailed Fail state with Error set to EscalationError.
Trigger a deliberate error to test your safety nets

The error handling code is in place, but you have only seen the happy path so far. The best way to prove your Retry and Catch blocks actually work is to deliberately break something and watch the state machine recover.

  • Click Edit on your state machine.
  • In Code mode, find the ModelId field inside the ClassifyTicket state. It currently looks like this:
"ModelId": "amazon.nova-lite-v1:0"
  • Change it to an invalid model ID so the line looks like this:
"ModelId": "amazon.nova-lite-v1:INVALID"
  • Click Save to save the change.

Before you start an execution, pause and think. What do you expect to happen when the state machine tries to call Bedrock with a model ID that does not exist?

  • Click Start execution.
  • Paste the same sample ticket JSON you used earlier as the input.
  • Click Start execution to confirm.

Watch the execution graph animate. The ClassifyTicket state does not fail immediately. Step Functions retries it 3 times, waiting longer between each attempt.

  • Click the ClassifyTicket state in the Graph view.
  • In the Step details pane on the right, select the Retry tab to see the individual retry attempts. Each failed attempt shows the reason for failure.

After all 3 retries are exhausted, the Catch block kicks in and routes the execution to the ClassificationFailed state. The execution ends as Failed with the error code ClassificationError.

What should I see in the execution timeline?

If you look at the timeline in the Graph view, red segments represent the failed retry attempts. Light gray segments between them represent the backoff wait periods. After the retries, the graph shows the transition to ClassificationFailed.

This is exactly the behavior you want in production. Instead of a silent crash, your state machine tried 3 times, waited longer between each attempt, and then routed to a named failure state that tells you what went wrong.

  • Now revert the model ID. Click Edit on the state machine.
  • Find the ModelId line and change it back to the correct value:
"ModelId": "amazon.nova-lite-v1:0"
  • Click Save.
  • Click Start execution with the same sample ticket input.
  • Click Start execution to confirm.

The execution should succeed end to end, with the full pipeline running through classification, branching, and storage or escalation as before. The happy path still works.

Execution still failing after reverting the model ID?

  • Make sure you saved the state machine after changing the model ID back. Edits in Code mode are not applied until you click Save.
  • Confirm the model ID is exactly amazon.nova-lite-v1:0 with no trailing spaces or typos.
  • Click the failed state in the execution graph and check the Step details pane for the specific error message. It tells you which service returned the error.

Help me debug why my execution fails after reverting the model ID.

Secret mission

Add an AI-Generated Response Suggestion

Your pipeline classifies tickets and routes them, but it never actually helps the customer. What if the high-confidence path could also draft a professional response before storing the result? In this secret mission, you will chain a second Bedrock AI call into your state machine to generate a suggested customer response on the auto-resolve path.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. All the resources in this project are serverless, so there are no ongoing compute costs while they sit idle. Bedrock charges only accrue when you run the pipeline.

Resources you used:

  • S3 bucket (ai-pipeline-results-{initials}-{date}) with sample ticket files and classification results.
  • Step Functions state machine with Bedrock, S3, and Lambda integrations.
  • Lambda function (EscalationHandler).
  • IAM role auto-generated for the state machine.

Keep everything running

No action needed. Choose this if you are still actively building or experimenting with your pipeline.

  • Step Functions gives you 4,000 free state transitions per month. This free tier does not expire.
  • Lambda gives you 1 million free requests per month for 12 months.
  • S3 gives you 5 GB of free storage for 12 months. The few JSON files in your bucket use a negligible amount.
  • Bedrock charges are pay-per-token with no idle cost. You only pay when you run the pipeline.
  • The IAM role has no cost.

Pause - I'll come back to this later

No action needed. These are all serverless resources with no idle cost.

  • Your state machine, Lambda function, and IAM role sit dormant until you run them again.
  • S3 storage costs for a few small JSON files are negligible (fractions of a cent per month).
  • Bedrock does not charge anything until you start a new execution.

Delete - I don't want to use this again

Remove all project resources to stop all potential charges and clean up your AWS account.

Delete the S3 bucket
  • Open the S3 console and choose General purpose buckets in the left navigation.
  • Select the radio button next to your ai-pipeline-results bucket, then click Empty.
  • Type permanently delete in the confirmation field and click Empty.
  • Click Exit to return to the bucket list.
  • Select your bucket again and click Delete.
  • Type the bucket name to confirm, then click Delete bucket.
Delete the Step Functions state machine
  • Open the Step Functions console and select State machines from the left navigation.
  • Select your state machine from the list.
  • Open the Actions dropdown and select Delete.
  • Confirm the deletion in the dialog that appears.
Delete the Lambda function
  • Open the Lambda console and go to the Functions page.
  • Select your EscalationHandler function.
  • Click Actions, then Delete.
  • Type confirm in the text input field and click Delete.
Delete the IAM role
  • Open the IAM console and select Roles from the left navigation.
  • Search for the auto-generated role name. It contains your state machine's name (for example, StepFunctions-YourStateMachineName-role).
  • Select the role and click Delete.
  • Enter the role name in the confirmation dialog and click Delete.

Nice Work!

Nice Work!

You just built a production-grade AI orchestration pipeline in AWS Step Functions. Your state machine calls an AI model, branches on its output, handles errors automatically, and routes results to different backends.

You've learned how to:

  • Build a multi-stage Step Functions state machine that calls Amazon Bedrock directly using an optimized integration, with no Lambda glue code needed for the AI call.
  • Implement conditional branching with a Choice state that routes tickets based on an AI confidence score, connecting the pipeline to real backends like S3 for auto-resolved tickets and Lambda for escalations.
  • Add production-grade error handling with Retry (exponential backoff) and Catch (fallback routing), then deliberately trigger errors to verify the pipeline recovers gracefully.
  • Secret Mission: Chain a second Bedrock call to generate AI-powered customer response suggestions on the high-confidence path before storing results in S3.

Ready to quiz yourself?