Bedrock Inline Agents with Guardrails in Go
Build a secure Go CLI agent with action groups, guardrails, and trace inspection.
Introduction
30 Second Summary
AI assistants that can browse the web, check the weather, or manage your calendar are everywhere. But giving an AI the power to take actions raises a critical question: how do you stop it from doing something harmful or leaking sensitive data?
In this project, you will build a Go CLI tool that dynamically composes an Amazon Bedrock inline agent at runtime, gives it tools to call, attaches a guardrail for safety, and prints the full reasoning trace showing how the agent thinks through each step.
What You'll Build
You ask your agent a question, it decides which tool to call, hands control back to your code to execute it, and synthesizes a final answer from the results. The guardrail silently blocks anything dangerous before it ever reaches the model.
By the end of this project, you'll have:
- A working inline agent that autonomously picks which tool to call, returns control to your Go code for execution, and weaves the results into a natural language answer.
- A programmatic guardrail that blocks harmful prompts before they reach the model and masks any PII (like email addresses or phone numbers) in outputs.
- Full orchestration trace output revealing the agent's reasoning chain: what it decided to do, why it chose that tool, what it observed, and how it composed the final response.
- Secret Mission: Extend your agent to hold multi-turn conversations with session memory and collaborate with a second agent that handles different tasks.
Are there any prerequisites?
This project assumes you have completed a prior Bedrock project in Go (such as the Converse or InvokeModel project) and already have the AWS CLI configured with Bedrock model access enabled for Claude. You should also have Go installed on your machine.
Before We Start
Before diving into code, let's lock in what you're about to build and why it matters.
Set Up the Bedrock Agent SDK Packages
To build an inline agent, your Go program needs two separate AWS SDK clients that talk to different parts of Amazon Bedrock.
The first client connects to the Bedrock control plane, where you create and manage governance resources like guardrails. The second connects to the agent runtime, where you invoke inline agents at execution time. In this step, you'll install both SDK packages, write a program that instantiates the two clients, and verify they can reach AWS.
In this step, get ready to:
- Initialize a Go module with the AWS SDK v2 Bedrock packages.
- Write a main program that instantiates both Bedrock clients.
- Verify connectivity by listing foundation models.
Initialize your Go module
A Go module is a directory with a go.mod file that tracks your project's dependencies. You need to create the project folder, initialize the module, then pull in the three SDK packages your program imports.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir bedrock-inline-agent
cd bedrock-inline-agent
- Initialize the Go module by running:
go mod init bedrock-inline-agent
- Install the three AWS SDK for Go v2 packages by running these commands:
go get github.com/aws/aws-sdk-go-v2/config
go get github.com/aws/aws-sdk-go-v2/service/bedrock
go get github.com/aws/aws-sdk-go-v2/service/bedrockagentruntime
You'll see Go downloading each package and its dependencies.
- Confirm your module files were created by running:
ls
You should see go.mod and go.sum in the output.
Not seeing go.mod?
Make sure you ran go mod init bedrock-inline-agent from inside the bedrock-inline-agent folder. If you're in the wrong directory, cd ~/Desktop/bedrock-inline-agent and try again.
help me troubleshoot my Go module initialization
What are these three packages?
- github.com/aws/aws-sdk-go-v2/config loads your AWS credentials and region from the CLI configuration.
- github.com/aws/aws-sdk-go-v2/service/bedrock is the control plane client for managing Bedrock resources like guardrails and model access.
- github.com/aws/aws-sdk-go-v2/service/bedrockagentruntime is the runtime client for invoking inline agents.
Write the main program
Your program needs to load the shared AWS configuration, then create one client for the control plane and another for the agent runtime. To prove everything connects, you'll call ListFoundationModels and print the count.
- Create a file called main.go in your bedrock-inline-agent folder and paste the following code:
package main
import (
"context"
"fmt"
"log"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/bedrock"
"github.com/aws/aws-sdk-go-v2/service/bedrockagentruntime"
)
func main() {
cfg, err := config.LoadDefaultConfig(context.Background())
if err != nil {
log.Fatalf("failed to load AWS config: %v", err)
}
bedrockClient := bedrock.NewFromConfig(cfg)
agentRuntimeClient := bedrockagentruntime.NewFromConfig(cfg)
// Confirm connectivity via ListFoundationModels
out, err := bedrockClient.ListFoundationModels(context.Background(), &bedrock.ListFoundationModelsInput{})
if err != nil {
log.Fatalf("failed to list foundation models: %v", err)
}
fmt.Printf("Connected to Bedrock. Found %d foundation models.\n", len(out.ModelSummaries))
fmt.Printf("Bedrock control plane client: %T\n", bedrockClient)
fmt.Printf("Bedrock agent runtime client: %T\n", agentRuntimeClient)
}
What does this code do?
- config.LoadDefaultConfig reads your AWS credentials and region from the same configuration your AWS CLI uses.
- bedrock.NewFromConfig(cfg) creates the control plane client for managing Bedrock resources (guardrails, model listings).
- bedrockagentruntime.NewFromConfig(cfg) creates the runtime client you'll use to invoke inline agents in Step 2.
- ListFoundationModels is a lightweight read-only call that proves your credentials, region, and Bedrock access are all working.
- Save main.go.
Build and run to confirm connectivity
The final check has two parts: a compile that proves your imports resolve, then a run that proves your credentials reach Bedrock.
- Compile the project by running:
go build ./...
If this completes with no output, your imports and types are correct.
Seeing import errors?
Run go mod tidy to resolve any missing transitive dependencies, then try go build ./... again.
If you see an error about a specific package not found, confirm you ran all three go get commands from the previous substep.
help me fix Go build errors for my Bedrock SDK project
Before you run the program, what do you think will happen? Will it connect successfully, or will something go wrong?
- Run the program by executing:
go run .
You should see output like this:
Connected to Bedrock. Found 42 foundation models.
Bedrock control plane client: *bedrock.Client
Bedrock agent runtime client: *bedrockagentruntime.Client
The exact model count depends on your region, but any number above zero confirms your connection is working.
Seeing "failed to list foundation models"?
This usually means your AWS credentials or region aren't set correctly. Confirm your CLI is configured by running aws sts get-caller-identity in a separate terminal.
If that works but Bedrock still fails, check that your region has Bedrock enabled and that you've requested model access in the Bedrock console.
help me debug my Bedrock ListFoundationModels connection error
✔️ Awesome, I've got everything!
Great. Double check you've saved main.go and that go build ./... still passes cleanly.
ⓧ I'd like to double check the full code
Here is the complete main.go at the end of this step:
package main
import (
"context"
"fmt"
"log"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/bedrock"
"github.com/aws/aws-sdk-go-v2/service/bedrockagentruntime"
)
func main() {
cfg, err := config.LoadDefaultConfig(context.Background())
if err != nil {
log.Fatalf("failed to load AWS config: %v", err)
}
bedrockClient := bedrock.NewFromConfig(cfg)
agentRuntimeClient := bedrockagentruntime.NewFromConfig(cfg)
// Confirm connectivity via ListFoundationModels
out, err := bedrockClient.ListFoundationModels(context.Background(), &bedrock.ListFoundationModelsInput{})
if err != nil {
log.Fatalf("failed to list foundation models: %v", err)
}
fmt.Printf("Connected to Bedrock. Found %d foundation models.\n", len(out.ModelSummaries))
fmt.Printf("Bedrock control plane client: %T\n", bedrockClient)
fmt.Printf("Bedrock agent runtime client: %T\n", agentRuntimeClient)
}
Your SDK foundation is solid. Both clients connect to AWS, and the control plane confirms your model access. Next up, you'll use the agent runtime client to invoke your first inline agent and see how it responds without any tools.
Invoke a Bare Inline Agent
Your SDK packages are installed and both clients connect to Bedrock successfully. Now it is time to see what an inline agent looks like with just a foundation model and instructions.
An inline agent is composed entirely at invocation time. You do not need to pre-create an agent resource in the console. You pass the model, the instruction, and the input text in a single API call and get a streaming response back. In this step you will invoke a bare agent with no action groups and intentionally hit the wall: the agent can chat, but it cannot take actions.
In this step, get ready to:
- Build the InvokeInlineAgent API call with a foundation model and instructions.
- Handle the streaming response by collecting payload chunks.
- Observe the agent's limitation when it has no action groups.
Build the InvokeInlineAgent call
The InvokeInlineAgent API requires a foundation model ID, an instruction string telling the agent its role, an input text prompt, a session ID to track the conversation, and enableTrace to expose the agent's reasoning. You will build a reusable function that accepts these parameters and returns the assembled response.
- Install the aws helper package (needed for pointer helpers like aws.String) by running this command:
go get github.com/aws/aws-sdk-go-v2/aws
What does this package provide?
The aws package exports utility functions like aws.String() and aws.Bool() that convert Go primitives into the pointer types the SDK expects for optional API fields.
- In main.go, replace the entire import block with the following updated imports and add a const block directly below it:
import (
"context"
"fmt"
"log"
"strings"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/bedrock"
"github.com/aws/aws-sdk-go-v2/service/bedrockagentruntime"
"github.com/aws/aws-sdk-go-v2/service/bedrockagentruntime/types"
)
const (
modelID = "anthropic.claude-3-sonnet-20240229-v1:0"
sessionID = "session-001"
instruction = "You are a helpful assistant."
)
What did you add?
- strings is used to build the response text from streamed chunks.
- aws provides pointer helper functions for API fields.
- types from bedrockagentruntime gives you the struct definitions for action groups, session state, and streaming events.
- The constants pin the model ID, session, and agent instruction so the rest of your code stays clean.
- Replace the entire main() function with this updated version that calls a new invokeInlineAgent function:
func main() {
cfg, err := config.LoadDefaultConfig(context.Background())
if err != nil {
log.Fatalf("failed to load AWS config: %v", err)
}
bedrockClient := bedrock.NewFromConfig(cfg)
agentRuntimeClient := bedrockagentruntime.NewFromConfig(cfg)
// Confirm connectivity
out, err := bedrockClient.ListFoundationModels(context.Background(), &bedrock.ListFoundationModelsInput{})
if err != nil {
log.Fatalf("failed to list foundation models: %v", err)
}
fmt.Printf("Connected to Bedrock. Found %d foundation models.\n", len(out.ModelSummaries))
// Invoke bare inline agent
response, err := invokeInlineAgent(context.Background(), agentRuntimeClient, "What is the weather in Seattle?", nil, nil, "", "")
if err != nil {
log.Fatalf("agent invocation failed: %v", err)
}
fmt.Printf("Agent response: %s\n", response)
}
What changed in main?
The connectivity check still runs first. After confirming Bedrock is reachable, the code calls invokeInlineAgent with the prompt "What is the weather in Seattle?" and prints whatever the agent returns. The nil arguments are placeholders for action groups and return-control results that you will add in later steps.
Handle the streaming response
The InvokeInlineAgent API returns a streaming event stream. Each event can be a text chunk, a return-control signal, or a trace event. For now you only need to collect the text chunks and assemble them into a single string.
- Below the main() function in main.go, add the first half of the invokeInlineAgent function. This builds the API input and makes the call:
func invokeInlineAgent(ctx context.Context, client *bedrockagentruntime.Client, inputText string, actionGroups []types.AgentActionGroup, returnControlResults []types.InvocationResultMember, guardrailID, guardrailVersion string) (string, error) {
input := &bedrockagentruntime.InvokeInlineAgentInput{
FoundationModel: aws.String(modelID),
Instruction: aws.String(instruction),
InputText: aws.String(inputText),
SessionId: aws.String(sessionID),
EnableTrace: aws.Bool(true),
ActionGroups: actionGroups,
}
if guardrailID != "" {
input.GuardrailConfiguration = &types.GuardrailConfigurationWithArn{
GuardrailIdentifier: aws.String(guardrailID),
GuardrailVersion: aws.String(guardrailVersion),
}
}
if len(returnControlResults) > 0 {
input.InlineSessionState = &types.InlineSessionState{
ReturnControlInvocationResults: returnControlResults,
}
}
stream, err := client.InvokeInlineAgent(ctx, input)
if err != nil {
return "", fmt.Errorf("InvokeInlineAgent error: %w", err)
}
defer stream.GetStream().Close()
What does this code do?
- The function accepts parameters for everything the inline agent needs: model, prompt, action groups, guardrail config, and return-control results. This makes the function reusable across future steps.
- aws.String() and aws.Bool() convert Go values into the pointer types the SDK requires for optional fields.
- The guardrail and return-control blocks are conditionally added. Right now both are empty, so they are skipped.
- InvokeInlineAgent returns a streaming response. You call GetStream() to access the event channel and defer Close() to clean up when done.
- Immediately after the defer stream.GetStream().Close() line (still inside the same function), add the stream processing and return logic:
var sb strings.Builder
for event := range stream.GetStream().Events() {
switch e := event.(type) {
case *types.ResponseStreamMemberChunk:
if e.Value.Bytes != nil {
sb.Write(e.Value.Bytes)
}
}
}
if err := stream.GetStream().Err(); err != nil {
return "", fmt.Errorf("stream error: %w", err)
}
return sb.String(), nil
}
What does the stream processing do?
- The for event := range stream.GetStream().Events() loop reads each event as it arrives from Bedrock.
- A type switch checks if the event is a ResponseStreamMemberChunk. Each chunk contains a Bytes field with a piece of the agent's text response.
- The bytes are written into a strings.Builder to assemble the full response efficiently.
- After the loop, stream.GetStream().Err() checks whether the stream closed due to an error.
- Save main.go.
- Confirm the code compiles by running:
go build ./...
You should see no output. A clean return means all imports resolve and the function signatures are correct.
Seeing an import error?
- Make sure you ran go get github.com/aws/aws-sdk-go-v2/aws earlier in this step. Without it, the aws import cannot resolve.
- Check that the types import path ends with bedrockagentruntime/types, not bedrock/types. They are different packages.
- Ensure the closing brace } of invokeInlineAgent is present at the end of the file.
Help me fix a Go build error in my Bedrock inline agent project.
Run the agent and observe its limitation
The code compiles. Now you will run it and ask the agent about the weather. Before you do, think about what the agent can possibly know without any action groups attached.
- Before you run this, predict: will the agent give you a real weather report for Seattle, or will it say something else? Why?
- Run the program:
go run main.go
You will see the connectivity message followed by the agent's response. The agent tells you it cannot access real-time weather data.
Why does the agent refuse?
The inline agent has a foundation model and an instruction, but no action groups. It has no tools to call, no APIs to reach, and no way to look up live data. It can only generate text from its training knowledge. This is the designed limitation you will solve in the next step by adding action groups with return control.
Seeing an access denied error?
- Confirm you have enabled model access for Claude in the Bedrock console. The model ID anthropic.claude-3-sonnet-20240229-v1:0 must be enabled in your AWS region.
- Check that your AWS CLI credentials have the bedrock:InvokeModel and bedrock:InvokeInlineAgent permissions.
- If you see a region-related error, verify your configured region supports Bedrock and has the Claude Sonnet model available.
Help me fix a Bedrock access denied error when invoking an inline agent.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.go and that go run main.go prints a response from the agent.
ⓧ I'd like to double check the full code
Your main.go should contain four sections: the import block, the constants block, the main() function, and the invokeInlineAgent function. Scroll up and confirm each one is present in order.
- The import block includes strings, aws, and bedrockagentruntime/types.
- The const block defines modelID, sessionID, and instruction.
- main() calls invokeInlineAgent with the weather prompt and prints the response.
- invokeInlineAgent builds the InvokeInlineAgentInput, calls the API, iterates ResponseStreamMemberChunk events, and returns the assembled string.
You have proved that a bare inline agent is limited to text generation from training data. Next up, you will give it real capabilities by adding action groups with the return-control pattern.
Add Action Groups with Return Control
In Step 2, the agent admitted it has no access to real-time data. It cannot look up weather or check the time because it has no tools. The gap is clear: the agent knows WHAT it wants to do, but it has no mechanism for HOW.
In this step, you give the agent tools via function-schema action groups. You set the executor to RETURN_CONTROL, which means the agent pauses execution and hands control back to your Go code whenever it needs a tool executed. Your code runs the tool locally, feeds the result back, and the agent continues. This is the core agentic pattern.
In this step, get ready to:
- Define an action group with a function schema declaring two tools.
- Implement the return-control orchestration loop.
- Observe the agent selecting and invoking your tools autonomously.
Define the action group function schemas
An action group declares what tools the agent can invoke. Each tool gets a name, a description the model reads, and a parameter schema. The RETURN_CONTROL executor tells the agent to pause and return control to your code rather than calling a Lambda.
- In main.go, find the instruction constant near the top of the file.
- Find this line:
instruction = "You are a helpful assistant."
- Replace it with the following, so the agent knows it can help with weather and time:
instruction = "You are a helpful assistant that can look up weather and time information."
- In the import block at the top of main.go, add "time" after the existing "strings" import.
- Add the following executeTool function below your main() function. This is the local tool executor your code calls when the agent requests an action:
func executeTool(functionName string, params map[string]types.FunctionParameter) string {
switch functionName {
case "getWeather":
city := ""
if p, ok := params["city"]; ok && p.Value != nil {
city = *p.Value
}
return fmt.Sprintf("Weather in %s: Sunny, 72°F, light breeze", city)
case "getCurrentTime":
tz := ""
if p, ok := params["timezone"]; ok && p.Value != nil {
tz = *p.Value
}
return fmt.Sprintf("Current time in %s: %s", tz, time.Now().Format("15:04:05"))
default:
return "Unknown function"
}
}
What does this code do?
The executeTool function is your local tool handler. When the agent decides to call getWeather, this function returns a hardcoded weather string. For getCurrentTime, it returns the actual system time.
In production, you would replace these hardcoded responses with real API calls. The point is that YOUR code controls execution, not the model.
- Now add the buildActionGroups function below executeTool. Start by defining the action group structure and the first tool:
func buildActionGroups() []types.AgentActionGroup {
return []types.AgentActionGroup{
{
ActionGroupName: aws.String("WeatherAndTimeTools"),
ActionGroupExecutor: &types.ActionGroupExecutorMemberCustomControl{
Value: types.CustomControlMethodReturnControl,
},
FunctionSchema: &types.FunctionSchemaMemberFunctions{
Value: []types.Function{
{
Name: aws.String("getWeather"),
Description: aws.String("Get the current weather for a city"),
Parameters: map[string]types.ParameterDetail{
"city": {
Type: types.TypeString,
Description: aws.String("The city to get weather for"),
Required: aws.Bool(true),
},
},
},
- Continue the function immediately after the getWeather definition by adding the getCurrentTime tool and closing the structure:
{
Name: aws.String("getCurrentTime"),
Description: aws.String("Get the current time in a timezone"),
Parameters: map[string]types.ParameterDetail{
"timezone": {
Type: types.TypeString,
Description: aws.String("The timezone (e.g. PST, EST, UTC)"),
Required: aws.Bool(true),
},
},
},
},
},
},
}
}
What does RETURN_CONTROL mean?
The ActionGroupExecutorMemberCustomControl with value CustomControlMethodReturnControl tells the agent: when you decide to call a tool, pause and return the function name and parameters to the calling program. Your code executes the tool and sends results back.
The alternative would be a Lambda ARN, where AWS invokes a Lambda function for you. RETURN_CONTROL keeps everything local and gives you full control over execution.
- Confirm your new functions compile by running:
go build ./...
The command should complete with no output, meaning zero compilation errors.
Seeing a compilation error?
- If you see imported and not used: "time", make sure executeTool is present and uses time.Now().
- If you see undefined: types.CustomControlMethodReturnControl, run go get to update your SDK packages. The enum was added in a recent version.
- Check that both code blocks for buildActionGroups are pasted together as one continuous function with matching braces.
Help me fix my Go compilation error for buildActionGroups
Implement the return-control orchestration loop
Your action group is defined, but the old invokeInlineAgent function does not know how to handle return-control events. You need a loop that invokes the agent, detects when it returns control requesting a tool call, executes the tool, feeds results back, and repeats until the agent produces a final text response.
- In main.go, delete the entire invokeInlineAgent function (everything from func invokeInlineAgent through its closing brace).
- In its place, add the runAgentLoop function. Start with the loop setup and agent invocation:
func runAgentLoop(ctx context.Context, client *bedrockagentruntime.Client, inputText string, actionGroups []types.AgentActionGroup, guardrailID, guardrailVersion string) (string, error) {
var returnControlResults []types.InvocationResultMember
currentInput := inputText
for {
input := &bedrockagentruntime.InvokeInlineAgentInput{
FoundationModel: aws.String(modelID),
Instruction: aws.String(instruction),
InputText: aws.String(currentInput),
SessionId: aws.String(sessionID),
EnableTrace: aws.Bool(true),
ActionGroups: actionGroups,
}
if guardrailID != "" {
input.GuardrailConfiguration = &types.GuardrailConfigurationWithArn{
GuardrailIdentifier: aws.String(guardrailID),
GuardrailVersion: aws.String(guardrailVersion),
}
}
if len(returnControlResults) > 0 {
input.InlineSessionState = &types.InlineSessionState{
ReturnControlInvocationResults: returnControlResults,
}
input.InputText = aws.String("")
returnControlResults = nil
}
What is the loop doing?
The loop re-invokes the agent each iteration. On the first pass, it sends the user's question. If the agent returned control in a previous iteration, it sends the tool results via returnControlInvocationResults instead. Per the API spec, when you include this field the inputText is ignored, so you set it to an empty string.
- Continue the function with the stream processing logic that detects both text chunks and return-control events:
stream, err := client.InvokeInlineAgent(ctx, input)
if err != nil {
return "", fmt.Errorf("InvokeInlineAgent error: %w", err)
}
var sb strings.Builder
var returnControlPayload *types.InlineAgentReturnControlPayload
for event := range stream.GetStream().Events() {
switch e := event.(type) {
case *types.ResponseStreamMemberChunk:
if e.Value.Bytes != nil {
sb.Write(e.Value.Bytes)
}
case *types.ResponseStreamMemberReturnControl:
returnControlPayload = &e.Value
}
}
stream.GetStream().Close()
if err := stream.GetStream().Err(); err != nil {
return "", fmt.Errorf("stream error: %w", err)
}
How does return-control detection work?
The event stream yields different types. ResponseStreamMemberChunk carries text bytes for the final answer. ResponseStreamMemberReturnControl carries an InlineAgentReturnControlPayload containing the function name and parameters the agent wants executed.
If the agent decided to use a tool, returnControlPayload will be non-nil after the stream completes. If it stayed nil, the agent produced a final text answer and the loop exits.
- Finish the function with the return-control processing logic and loop exit:
if returnControlPayload != nil {
fmt.Printf("[ReturnControl] Agent requested tool invocation\n")
for _, inv := range returnControlPayload.InvocationInputs {
if fi, ok := inv.(*types.InvocationInputMemberFunctionInvocationInput); ok {
functionName := aws.ToString(fi.Value.Function)
result := executeTool(functionName, fi.Value.Parameters)
fmt.Printf("[Tool] %s -> %s\n", functionName, result)
returnControlResults = append(returnControlResults, &types.InvocationResultMemberFunctionResult{
Value: types.FunctionResult{
ActionGroup: fi.Value.ActionGroup,
Function: fi.Value.Function,
ResponseBody: map[string]types.ContentBody{
"TEXT": {Body: aws.String(result)},
},
},
})
}
}
continue
}
return sb.String(), nil
}
}
What does the result assembly do?
When the agent returns control, you iterate over its InvocationInputs (up to 5 per the API). For each one, you extract the function name and parameters, call executeTool, and build an InvocationResultMemberFunctionResult that packages the output. The continue keyword re-enters the loop and sends these results back to the agent.
When there is no return-control payload, the agent produced its final answer. The loop returns the assembled text.
- Now update the main() function to use buildActionGroups and runAgentLoop. Replace everything inside main() from the // Confirm connectivity comment onward with this:
out, err := bedrockClient.ListFoundationModels(context.Background(), &bedrock.ListFoundationModelsInput{})
if err != nil {
log.Fatalf("failed to list foundation models: %v", err)
}
fmt.Printf("Connected to Bedrock. Found %d foundation models.\n", len(out.ModelSummaries))
actionGroups := buildActionGroups()
response, err := runAgentLoop(context.Background(), agentRuntimeClient, "What is the weather in Seattle?", actionGroups, "", "")
if err != nil {
log.Fatalf("agent loop failed: %v", err)
}
fmt.Printf("Final Agent Response: %s\n", response)
- Save main.go and confirm the complete program compiles:
go build ./...
No output means no errors. Your orchestration loop is ready to run.
Seeing undefined or mismatched type errors?
- Ensure you deleted the entire old invokeInlineAgent function. If any reference to it remains in main(), the build fails.
- Verify all three code blocks for runAgentLoop are pasted in sequence as one continuous function. Missing a closing brace or continue statement breaks the logic.
- Check that main() calls runAgentLoop (not the old invokeInlineAgent).
Help me fix my runAgentLoop compilation errors
Run the agent and observe the return-control pattern
Before you run, think about what you expect to happen. The agent receives the same weather question as Step 2. But now it has an action group with a getWeather tool available.
- Before you run this, predict: will the agent try to answer from its training data again, or will it call your tool? Why?
- Run the program:
go run main.go
You should see output similar to this:
Connected to Bedrock. Found XX foundation models.
[ReturnControl] Agent requested tool invocation
[Tool] getWeather -> Weather in Seattle: Sunny, 72°F, light breeze
Final Agent Response: Based on the current weather data, Seattle is experiencing sunny skies with a temperature of 72°F and a light breeze.
The agent chose to call your getWeather tool instead of guessing. Your code executed the tool locally and fed the result back. The agent then synthesized a natural language answer incorporating your tool's output.
Why is this pattern powerful?
The model decides WHAT to do (call getWeather with city=Seattle). Your code decides HOW to do it (make an API call, query a database, or return hardcoded data). This separation means you can swap tool implementations without retraining the model, and you retain full control over what actually executes.
Agent still responding without calling tools?
- Make sure actionGroups is passed to runAgentLoop and that buildActionGroups() is called in main().
- Verify the instruction constant mentions weather and time. Without this hint, the model may not connect the question to the available tools.
- Check that ActionGroups: actionGroups appears in the InvokeInlineAgentInput struct inside runAgentLoop.
Help me debug why my inline agent isn't calling my tools
✔️ Awesome, I've got everything!
Great. Double check that you have saved main.go and that go run main.go produces the [ReturnControl] and Final Agent Response lines.
ⓧ I'd like to double check the full code
Your main.go is now quite long. Scroll through and confirm these functions appear in order:
- main() calling buildActionGroups() and runAgentLoop().
- buildActionGroups() returning a slice with one AgentActionGroup containing two functions.
- executeTool() with a switch on getWeather and getCurrentTime.
- runAgentLoop() containing the for-loop that detects ResponseStreamMemberReturnControl events.
The old invokeInlineAgent function should be completely gone. If go build ./... passes with no output, your file is correct.
Your agent now has tools and knows how to use them. Next up, you will create a guardrail to protect the agent from harmful prompts and mask sensitive information in its outputs.
Create and Attach a Guardrail
Your inline agent can now call tools and synthesize results. That is powerful, but it is also unguarded. Anyone can send a harmful prompt and the agent will process it without question.
In this step, you will create a Bedrock guardrail programmatically via the control plane client. The guardrail adds content safety filters and PII masking, then you will attach it to your inline agent so every invocation is protected.
In this step, get ready to:
- Create a guardrail with content filters and PII protection using the Bedrock control plane SDK.
- Publish a numbered guardrail version for production use.
- Attach the guardrail to your inline agent and verify it blocks harmful prompts.
Create the guardrail with content and PII filters
The Bedrock control plane client exposes CreateGuardrail, which takes a content policy and a sensitive information policy. You will set all five harmful content categories to HIGH strength and configure PII anonymization for email and phone numbers.
- In main.go, find the import block at the top of the file. Add a new import line for the bedrock types package with an alias. Your import block should now look like this:
import (
"context"
"fmt"
"log"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/bedrock"
bedrocktypes "github.com/aws/aws-sdk-go-v2/service/bedrock/types"
"github.com/aws/aws-sdk-go-v2/service/bedrockagentruntime"
"github.com/aws/aws-sdk-go-v2/service/bedrockagentruntime/types"
)
Why the alias?
Both the bedrock and bedrockagentruntime packages have a types sub-package. Without the bedrocktypes alias, Go would not know which types package you mean.
- Below the buildActionGroups function, add the first half of the createGuardrail function. This calls CreateGuardrail with content filters and PII protection:
func createGuardrail(ctx context.Context, client *bedrock.Client) (string, string, error) {
// Create guardrail with content safety and PII filters
createOut, err := client.CreateGuardrail(ctx, &bedrock.CreateGuardrailInput{
Name: aws.String("inline-agent-guardrail"),
BlockedInputMessaging: aws.String("This request was blocked by content safety filters."),
BlockedOutputsMessaging: aws.String("The response was blocked by content safety filters."),
ContentPolicyConfig: &bedrocktypes.GuardrailContentPolicyConfig{
FiltersConfig: []bedrocktypes.GuardrailContentFilterConfig{
{Type: bedrocktypes.GuardrailContentFilterTypeHate, InputStrength: bedrocktypes.GuardrailFilterStrengthHigh, OutputStrength: bedrocktypes.GuardrailFilterStrengthHigh},
{Type: bedrocktypes.GuardrailContentFilterTypeViolence, InputStrength: bedrocktypes.GuardrailFilterStrengthHigh, OutputStrength: bedrocktypes.GuardrailFilterStrengthHigh},
{Type: bedrocktypes.GuardrailContentFilterTypeInsults, InputStrength: bedrocktypes.GuardrailFilterStrengthHigh, OutputStrength: bedrocktypes.GuardrailFilterStrengthHigh},
{Type: bedrocktypes.GuardrailContentFilterTypeSexual, InputStrength: bedrocktypes.GuardrailFilterStrengthHigh, OutputStrength: bedrocktypes.GuardrailFilterStrengthHigh},
{Type: bedrocktypes.GuardrailContentFilterTypeMisconduct, InputStrength: bedrocktypes.GuardrailFilterStrengthHigh, OutputStrength: bedrocktypes.GuardrailFilterStrengthHigh},
},
},
SensitiveInformationPolicyConfig: &bedrocktypes.GuardrailSensitiveInformationPolicyConfig{
PiiEntitiesConfig: []bedrocktypes.GuardrailPiiEntityConfig{
{Type: bedrocktypes.GuardrailPiiEntityTypeEmail, Action: bedrocktypes.GuardrailSensitiveInformationActionAnonymize},
{Type: bedrocktypes.GuardrailPiiEntityTypePhone, Action: bedrocktypes.GuardrailSensitiveInformationActionAnonymize},
},
},
})
if err != nil {
return "", "", fmt.Errorf("CreateGuardrail error: %w", err)
}
What does this code do?
- ContentPolicyConfig defines five harmful content categories (HATE, VIOLENCE, INSULTS, SEXUAL, MISCONDUCT). Each is set to HIGH strength for both input and output, meaning any content matching these categories is blocked.
- SensitiveInformationPolicyConfig configures PII detection. EMAIL and PHONE entities are set to ANONYMIZE, which masks them in output rather than blocking the entire response.
- BlockedInputMessaging and BlockedOutputsMessaging define what the user sees when content is blocked.
Publish the guardrail version
A newly created guardrail exists only as a DRAFT. Agents cannot use the DRAFT version. You must publish a numbered version before attaching it to your inline agent.
- Immediately below the code you just added (still inside the createGuardrail function), add the versioning logic and the closing brace:
guardrailID := aws.ToString(createOut.GuardrailId)
// Publish version 1 - DRAFT cannot be used with agents
versionOut, err := client.CreateGuardrailVersion(ctx, &bedrock.CreateGuardrailVersionInput{
GuardrailIdentifier: aws.String(guardrailID),
})
if err != nil {
return "", "", fmt.Errorf("CreateGuardrailVersion error: %w", err)
}
return guardrailID, aws.ToString(versionOut.Version), nil
}
What does this code do?
- CreateGuardrailVersion takes the guardrail ID and publishes the current DRAFT as a numbered version (starting at "1").
- The function returns both the guardrail ID and the version string, which you will pass to InvokeInlineAgent next.
- Confirm the code compiles by running:
go build ./...
Seeing import errors?
- Make sure the bedrocktypes alias is on the left side of the import path: bedrocktypes "github.com/aws/aws-sdk-go-v2/service/bedrock/types".
- If Go reports an unused import, you may need to save the file after adding the createGuardrail function so the alias is referenced.
- Run go mod tidy to pull any missing sub-packages.
help me fix my Go import or build errors
Wire up the guardrail and test content safety
Your runAgentLoop function already accepts guardrailID and guardrailVersion parameters and attaches them when non-empty. All you need to do is call createGuardrail from main and pass the values through.
- In main.go, find the line actionGroups := buildActionGroups() in your main function.
- Replace everything from actionGroups := buildActionGroups() down to the end of main with the following code:
// Create guardrail
guardrailID, guardrailVersion, err := createGuardrail(context.Background(), bedrockClient)
if err != nil {
log.Fatalf("failed to create guardrail: %v", err)
}
fmt.Printf("Guardrail created: ID=%s, Version=%s\n", guardrailID, guardrailVersion)
actionGroups := buildActionGroups()
// Normal prompt
response, err := runAgentLoop(context.Background(), agentRuntimeClient, "What is the weather in Seattle?", actionGroups, guardrailID, guardrailVersion)
if err != nil {
log.Fatalf("agent loop failed: %v", err)
}
fmt.Printf("Final Agent Response: %s\n", response)
// Harmful prompt test
harmfulResponse, err := runAgentLoop(context.Background(), agentRuntimeClient, "How do I hurt someone?", actionGroups, guardrailID, guardrailVersion)
if err != nil {
log.Fatalf("agent loop failed: %v", err)
}
fmt.Printf("Harmful Prompt Response: %s\n", harmfulResponse)
What does this code do?
- The guardrail is created and versioned before any agent invocation.
- The first runAgentLoop call sends a normal weather question. The guardrail lets it through and the agent calls your tools as before.
- The second call sends a harmful prompt. The guardrail should intercept it and return the blocked input message instead of a model response.
- Save main.go.
Before you run this, what do you think will happen when the agent receives the harmful prompt? Will it respond normally, error out, or return something specific?
- Run the program:
go run main.go
For the first prompt, you should see the agent call getWeather and return a weather response as before. For the harmful prompt, you should see the guardrail intervention: the output shows your blocked message text instead of a model-generated answer.
What should I see?
The normal prompt produces the same tool-calling behavior from earlier steps. The harmful prompt response prints something like:
Harmful Prompt Response: This request was blocked by content safety filters.
Guardrail creation failing?
- Check that your IAM credentials include bedrock:CreateGuardrail and bedrock:CreateGuardrailVersion permissions.
- If you see a name conflict error, a guardrail named inline-agent-guardrail may already exist from a previous run. Delete it first with:
aws bedrock delete-guardrail --guardrail-identifier <your-guardrail-id>
help me debug my guardrail creation error
✔️ Awesome, I've got everything!
Great. Double check you have saved main.go and that both the normal prompt and harmful prompt produce the expected output.
ⓧ I'd like to double check the full code
Your main.go is now quite long. Scroll up and confirm these sections are present in order:
- Import block with the bedrocktypes alias.
- Constants (modelID, sessionID, instruction).
- main function calling createGuardrail, then buildActionGroups, then two runAgentLoop calls (normal and harmful).
- createGuardrail function with CreateGuardrail + CreateGuardrailVersion.
- buildActionGroups function (unchanged from Step 3).
- executeTool function (unchanged from Step 3).
- runAgentLoop function with guardrailConfiguration logic (unchanged from Step 3).
Your agent now has a governance layer. Harmful prompts get stopped before they ever reach the model, and any PII that slips through in outputs gets masked. Next up, you will make this invisible reasoning visible by enabling and inspecting orchestration traces.
Enable and Inspect Orchestration Traces
Your agent calls tools and the guardrail blocks harmful inputs. But right now, you cannot see how the agent reasons through each request.
Orchestration traces expose the full reasoning loop: the model's rationale for choosing a tool, the parameters it predicted, the observation it collected from your code, and the final synthesis decision. This is the primary mechanism for debugging and optimizing agents in production. It maps directly to Domain 5 of the AWS Certified Generative AI Developer - Professional (AIP-C01) exam: Testing, Validation, and Troubleshooting.
In this step, get ready to:
- Handle trace events from the agent response stream and print the orchestration details.
- Format trace output as a visible reasoning chain: Rationale, Action, Observation, Final Response.
- Run a multi-tool scenario and observe multiple orchestration loop iterations.
Add a trace handler function
The response stream emits InlineAgentTracePart events alongside payload and return-control events. Each trace part contains a Trace union that can be an OrchestrationTrace, PreProcessingTrace, or PostProcessingTrace.
- In main.go, add the following printTrace function above the runAgentLoop function:
func printTrace(trace types.Trace) {
// Handle orchestration traces (rationale, action, observation)
if orch := trace.OrchestrationTrace; orch != nil {
if orch.Rationale != nil && orch.Rationale.Text != nil {
fmt.Printf(" [Rationale] %s\n", *orch.Rationale.Text)
}
if orch.InvocationInput != nil {
if fi := orch.InvocationInput.FunctionInvocationInput; fi != nil {
fmt.Printf(" [Action] Function: %s\n", aws.ToString(fi.Function))
for k, v := range fi.Parameters {
fmt.Printf(" Param %s = %s\n", k, aws.ToString(v.Value))
}
}
}
if orch.Observation != nil {
if orch.Observation.FunctionResult != nil {
for k, v := range orch.Observation.FunctionResult.ResponseBody {
fmt.Printf(" [Observation] %s: %s\n", k, aws.ToString(v.Body))
}
}
}
}
// Handle preprocessing traces (input validation)
if pre := trace.PreProcessingTrace; pre != nil {
if pre.ModelInvocationOutput != nil && pre.ModelInvocationOutput.ParsedResponse != nil {
fmt.Printf(" [PreProcessing] isValid=%v\n", aws.ToBool(pre.ModelInvocationOutput.ParsedResponse.IsValid))
}
}
// Handle postprocessing traces (final formatting)
if post := trace.PostProcessingTrace; post != nil {
if post.ModelInvocationOutput != nil && post.ModelInvocationOutput.ParsedResponse != nil {
fmt.Printf(" [PostProcessing] text=%s\n", aws.ToString(post.ModelInvocationOutput.ParsedResponse.Text))
}
}
}
What does this function do?
The printTrace function inspects a Trace union and prints the fields that matter most for understanding the agent's reasoning chain.
- OrchestrationTrace.Rationale shows WHY the agent chose a particular tool.
- OrchestrationTrace.InvocationInput shows WHICH function and parameters the agent predicted.
- OrchestrationTrace.Observation shows the result your code fed back after executing the tool.
- PreProcessingTrace confirms whether the input passed the agent's validation step.
- PostProcessingTrace shows the final formatted response before it reaches the user.
Wire trace events into the orchestration loop
The printTrace function exists but nothing calls it yet. You need to add a third case to the event switch in runAgentLoop and add iteration tracking so each loop pass is clearly labelled in the output.
- In runAgentLoop, find the line:
currentInput := inputText
- Add an iteration counter directly below it, so it looks like this:
currentInput := inputText
iteration := 0
- Find the line for { inside runAgentLoop. Add iteration tracking as the first two lines inside the loop:
for {
iteration++
fmt.Printf("\n--- Orchestration Iteration %d ---\n", iteration)
- Now find the event switch inside the same function. Locate the case *types.ResponseStreamMemberReturnControl: case. Add a new case directly below it for trace events:
case *types.ResponseStreamMemberReturnControl:
returnControlPayload = &e.Value
case *types.ResponseStreamMemberTrace:
if e.Value.Trace != nil {
printTrace(*e.Value.Trace)
}
What does this change?
The iteration counter prints a header each time the agent loops (once per tool call plus the final response). The new ResponseStreamMemberTrace case catches every trace event the agent emits and forwards it to printTrace.
Together, these changes make the invisible orchestration loop visible as a structured log: each iteration shows the agent's reasoning, its action choice, and the observation it receives before deciding whether to loop again or return a final answer.
- Save main.go.
- Confirm the code compiles by running:
go build ./...
Seeing a compile error?
- Check that printTrace is defined outside of any other function. It should sit between executeTool and runAgentLoop.
- Make sure the new case *types.ResponseStreamMemberTrace: line is inside the switch e := event.(type) block, not outside it.
- Verify you have the closing brace for the if e.Value.Trace != nil block.
Help me fix a compile error in my trace handler code.
Run a multi-tool scenario and inspect the traces
A single-tool query only shows one orchestration loop. To see the agent iterate multiple times, you need a prompt that requires two separate tool calls.
- In main, find the lines that call runAgentLoop for the normal prompt. Replace that section (from the // Normal prompt comment through the print of response) with this multi-tool query:
fmt.Println("\n=== Multi-Tool Query ===")
response, err := runAgentLoop(context.Background(), agentRuntimeClient, "What is the weather in Seattle and what time is it in PST?", actionGroups, guardrailID, guardrailVersion)
if err != nil {
log.Fatalf("agent loop failed: %v", err)
}
fmt.Printf("\nFinal Agent Response: %s\n", response)
- Also update the harmful prompt section to use a section header. Find the // Harmful prompt test comment and replace it with:
fmt.Println("\n=== Harmful Prompt Test ===")
- Save main.go.
Why a multi-tool prompt?
When the agent receives a question that requires two pieces of information (weather AND time), it must loop through the orchestration cycle twice. Each loop produces its own trace events: a rationale explaining why that tool is needed, the action prediction with parameters, and the observation after your code executes the tool.
Only after both tools return does the agent synthesize a final natural-language answer combining both results. The traces make this entire chain visible.
Before you run this, think about what you expect to see. How many orchestration iterations will the agent need to answer a two-tool question?
- Run the program:
go run main.go
You should see structured trace output showing the full reasoning chain across multiple iterations. Each iteration prints [Rationale] (the model's reasoning), [Action] (which function it chose), and [Observation] (the result fed back). The agent loops at least twice for the two tools, then produces a final synthesized response.
What should the output look like?
You will see output similar to this pattern:
- Iteration 1: [PreProcessing] isValid=true, then [Rationale] explaining the agent needs weather data, then [Action] Function: getWeather with Param city = Seattle.
- Iteration 2: [Observation] showing the weather result, then [Rationale] explaining it still needs time data, then [Action] Function: getCurrentTime with Param timezone = PST.
- Final iteration: [Observation] with the time result, then the synthesized final answer combining both.
- The harmful prompt test shows the guardrail blocking before any orchestration occurs.
Not seeing trace output?
- Confirm EnableTrace is set to aws.Bool(true) in your InvokeInlineAgentInput.
- Check that the case *types.ResponseStreamMemberTrace: line exists in your event switch and is not commented out.
- If you see iterations but no [Rationale] lines, the model may be skipping the rationale for simple queries. Try the multi-tool prompt to force a more complex reasoning chain.
Help me debug why trace events are not printing.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.go with all three changes: the printTrace function, the iteration tracking plus trace case in runAgentLoop, and the multi-tool prompt in main.
ⓧ I'd like to double check the full code
Your main.go is now large. Here are the key sections to verify in order. Scroll through your file and confirm each is present.
- Imports: context, fmt, log, strings, time, plus the AWS SDK packages (aws, config, bedrock, bedrocktypes, bedrockagentruntime, types).
- Constants: modelID, sessionID, instruction.
- main: creates clients, creates guardrail, builds action groups, runs multi-tool query with === Multi-Tool Query === header, runs harmful prompt test with === Harmful Prompt Test === header.
- createGuardrail: unchanged from the previous step.
- buildActionGroups: unchanged from the previous step.
- executeTool: unchanged from the previous step.
- printTrace: handles OrchestrationTrace (Rationale, InvocationInput, Observation), PreProcessingTrace, and PostProcessingTrace.
- runAgentLoop: has iteration counter, prints --- Orchestration Iteration %d --- at start of each loop, event switch handles ResponseStreamMemberChunk, ResponseStreamMemberReturnControl, and ResponseStreamMemberTrace.
You now have full visibility into how the agent thinks. Every rationale, action choice, and observation is printed as a structured log. This is the same debugging approach you would use in production to diagnose slow agents, incorrect tool selections, or unexpected guardrail interventions.
Secret mission
Multi-Turn Sessions and Multi-Agent Collaboration
Your inline agent works great with its own tools. But what happens when a task requires capabilities outside its toolset? Extend your agent into a supervisor that delegates sub-tasks to a specialized collaborator agent, and add persistent multi-turn conversation so users can ask follow-up questions in the same session.
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. This project created one cloud resource (a Bedrock guardrail) and local files. The guardrail has no ongoing cost when idle. You only pay when it evaluates content.
Resources you used:
- Bedrock Guardrail (inline-agent-guardrail) with one published version in your AWS account.
- Local project folder bedrock-inline-agent on your Desktop containing main.go, go.mod, and go.sum.
Keep everything running
No action needed. Choose this if you plan to extend this agent or reuse the guardrail in future Bedrock projects.
- Your guardrail persists in your AWS account at no ongoing cost. You only pay when it actively evaluates content during an agent invocation.
- Your local bedrock-inline-agent folder stays on your Desktop ready for future edits.
- No Lambda functions, persistent agents, or other infrastructure was created. InvokeInlineAgent is fully serverless and stateless.
Pause - I'll come back to this later
Shut down any running processes to free up system resources. Since the guardrail has no idle cost, there is nothing to pause on the AWS side.
- If your Go program is still running in the terminal, press Ctrl+C to stop it.
- Your guardrail remains in your account at zero cost until you invoke it again.
- Your local files remain unchanged on your Desktop.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below to fully clean up.
- Delete the Bedrock guardrail by running this command in your terminal (replace the guardrail ID with the one your program printed at startup):
- Delete your local project folder.
Delete the guardrail:
aws bedrock delete-guardrail --guardrail-identifier [[GUARDRAIL_ID="your guardrail ID from the program output"]]
Find your guardrail ID
Your program printed the guardrail ID when it started (the line that says Guardrail created: ID=...). If you no longer have that output, run aws bedrock list-guardrails and look for the one named inline-agent-guardrail.
Delete the local project folder on macOS/Linux:
cd ~/Desktop
rm -rf bedrock-inline-agent
On Windows:
cd ~\Desktop
rmdir /s /q bedrock-inline-agent
Nice Work!
Nice Work!
You just built a production-grade inline agent in Go that dynamically composes capabilities at runtime, enforces content safety with a programmatic guardrail, and exposes its full reasoning chain through orchestration traces.
You've learned how to:
- Compose an inline Bedrock agent at runtime with action groups using the return-control pattern, where the agent decides what to do and your Go code decides how to execute it locally.
- Create and version a Bedrock guardrail programmatically with content filters and PII protection, then attach it to your agent to block harmful prompts and mask sensitive data.
- Inspect the full orchestration trace showing rationale, action prediction, observation, and final response. This maps directly to Domains 1-3 and 5 of the AWS Certified Generative AI Developer - Professional (AIP-C01) exam.
- Secret Mission: Extended your agent to support multi-turn conversation with session state persistence and multi-agent collaboration using supervisor routing.
Ready to quiz yourself?