Bedrock From the Inside Out
Build a Go CLI that explores Bedrock's architecture via control and data plane APIs.
Introduction
30 Second Summary
Cloud AI platforms give you access to dozens of foundation models from different providers. Knowing how to programmatically discover, invoke, and swap between them without rewriting your code is what separates a fragile prototype from a production-grade system.
In this project, you will build a Go CLI tool that explores Amazon Bedrock's architecture from the inside out. You'll start at model discovery, move through raw low-level inference, hit a real limitation that breaks your code, and rebuild with model-agnostic APIs that work across providers with zero changes.
What You'll Build
A Go program you can run from your terminal that queries Bedrock's model catalog, sends inference requests to multiple providers, and streams back responses token-by-token in real time.
By the end of this project, you'll have:
- A working control plane query that discovers and filters available models by provider, modality, and inference type.
- Both a raw InvokeModel call (with hand-crafted JSON) and a model-agnostic Converse call that swaps between Amazon Nova and Anthropic Claude with identical code.
- Real-time streaming output via ConverseStream that prints tokens as they arrive, with usage statistics and cross-region inference profile routing.
- Secret Mission: Give a foundation model access to a local function through Bedrock's native tool use (function calling) mechanism, and watch it decide when to call it.
Are there any prerequisites?
You'll need an AWS account with CLI credentials configured, Go 1.21+ installed, and familiarity with Go interfaces and type assertions. If you plan to use Anthropic Claude models (and you will), you'll also need to submit a one-time usage form in the Bedrock console.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up Your Environment
Your Go program needs three things before it can talk to Bedrock: a Go module, the correct AWS SDK packages, and confirmed API access.
In this step, you'll create your project, install the SDK dependencies, and write a proof-of-life program that calls Bedrock's control plane to list available foundation models.
In this step, get ready to:
- Create a Go module and install AWS SDK v2 dependencies.
- Write and run a program that lists Bedrock foundation models.
- Submit the Anthropic one-time usage form for later steps.
Create the project and install dependencies
Every Go project starts with a module and its dependencies. You need the AWS SDK v2 config package for credential loading, plus two Bedrock-specific packages: one for the control plane (model discovery) and one for the data plane (inference).
- Move to your Desktop by running this command:
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir bedrock-explorer
cd bedrock-explorer
- Initialize a Go module by running:
go mod init bedrock-explorer
- Install the three AWS SDK v2 packages by running:
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/bedrockruntime
- Confirm the module and dependencies are set up by running:
cat go.mod
You should see your module name and the three SDK packages listed under require.
What are these two Bedrock packages?
The github.com/aws/aws-sdk-go-v2/service/bedrock package is the control plane. It handles discovery and management (listing models, checking capabilities).
The github.com/aws/aws-sdk-go-v2/service/bedrockruntime package is the data plane. It handles inference (sending prompts and receiving responses). They hit different AWS service endpoints.
Getting a network error on go get?
Check that your terminal can reach the internet. If you're behind a corporate proxy, ensure GOPROXY is set correctly. Run go env GOPROXY to check.
help me troubleshoot my Go module dependency installation
Write your first Bedrock call
Your first program will load AWS credentials, create a Bedrock control plane client, and call ListFoundationModels to confirm everything connects.
- Create a file called main.go in your bedrock-explorer folder and paste the following package declaration, imports, and region constant:
package main
import (
"context"
"fmt"
"log"
// AWS config loads credentials and region settings
"github.com/aws/aws-sdk-go-v2/config"
// Bedrock control plane client for model discovery
"github.com/aws/aws-sdk-go-v2/service/bedrock"
)
// All Bedrock calls in this project target us-east-1
const region = "us-east-1"
What does this set up?
The config import loads your AWS credentials from ~/.aws/credentials (or environment variables). The bedrock import gives you the control plane client type used for discovery operations like listing models.
- Below the const line, add the main function that loads config, creates the client, and prints model IDs:
func main() {
// Create a background context for all AWS calls
ctx := context.Background()
cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(region))
if err != nil {
log.Fatalf("Failed to load AWS config: %v", err)
}
// Create a control plane client for Bedrock model discovery
controlClient := bedrock.NewFromConfig(cfg)
// List all available foundation models
result, err := controlClient.ListFoundationModels(ctx, &bedrock.ListFoundationModelsInput{})
if err != nil {
log.Fatalf("ListFoundationModels failed: %v", err)
}
// Print the first 10 model IDs to confirm API access
fmt.Printf("Found %d foundation models. First 10:\n", len(result.ModelSummaries))
for i, m := range result.ModelSummaries {
if i >= 10 {
break
}
fmt.Println(*m.ModelId)
}
}
What does this code do?
- config.LoadDefaultConfig reads your AWS credentials and creates a session scoped to us-east-1.
- bedrock.NewFromConfig creates a control plane client. This client talks to the Bedrock management endpoint for operations like listing and filtering models.
- ListFoundationModels returns every foundation model available in your account and region. Printing the first 10 proves your credentials, region, and SDK setup all work.
- Save main.go.
Before you run this, what do you expect to see? If your credentials and region are correct, the program should print a count and a list of model IDs. If something is misconfigured, you'll see an error.
- Run your program by executing:
go run main.go
You should see output starting with a line like Found X foundation models. First 10: followed by model IDs such as amazon.nova-lite-v1:0 and anthropic.claude-3-haiku-20240307-v1:0.
Seeing an access denied or credentials error?
Confirm your AWS CLI is configured for the correct account by running aws sts get-caller-identity. If that fails, your credentials are not loaded. Check that ~/.aws/credentials exists and has valid keys.
If you see a region error, ensure your config sets us-east-1 explicitly. Some SDK versions fall back to AWS_REGION environment variable if not set in code.
help me debug my Bedrock ListFoundationModels access error
✔️ Awesome, I've got everything!
Great. Confirm you've saved main.go and your output shows model IDs.
ⓧ I'd like to double check the full code
Your main.go should have two sections in order:
- The package declaration, imports (context, fmt, log, config, bedrock), and the region constant set to "us-east-1".
- The main function that calls config.LoadDefaultConfig, creates controlClient via bedrock.NewFromConfig, calls ListFoundationModels, and prints the first 10 model IDs with fmt.Println(*m.ModelId).
Scroll up and confirm both chunks are present and in order. The program should compile and run without errors.
Submit the Anthropic usage form
In a later step, you'll invoke Anthropic Claude models through Bedrock. Anthropic requires a one-time usage form submission before you can call their models. Access is granted immediately after submission.
- Open the Amazon Bedrock console at https://console.aws.amazon.com/bedrock/.
- Make sure you are in the us-east-1 region (check the region selector in the top-right corner).
- In the left navigation pane, select any Anthropic model from the model catalog.
- Click Submit use case details.
- Fill in a description of your intended use (e.g. "learning project to explore Bedrock APIs") and a website URL. If you don't have a company site, use your GitHub profile or a personal portfolio URL.
- Click Submit form.
Access is granted immediately after a successful submission. You will be able to invoke Claude models in Step 4.
What if I already submitted this form?
The form is required only once per AWS account (or once per organization management account). If you've previously submitted it for any Anthropic model, you don't need to do it again. You can verify by selecting an Anthropic model in the catalog. If you don't see the Submit use case details prompt, you're already approved.
Your environment is wired up and proven. Next up, you'll use the control plane to intelligently filter and explore the model catalog before making any inference calls.
Explore the Control Plane
In the previous step you confirmed that your credentials and SDK setup work by dumping every model Bedrock knows about. That raw dump is not useful for making decisions.
Before you invoke a model, you need to know what is available and how Bedrock categorizes its offerings. The control plane (the bedrock package) is architecturally separate from the data plane (the bedrockruntime package). This step teaches you why that separation exists and how to query the control plane for intelligent model selection.
In this step, get ready to:
- Filter the model catalog by provider, modality, and inference type.
- Query for fine-tunable models and compare them against your filtered set.
- Document the control plane vs data plane architecture in code.
Write the filtered discovery function
The ListFoundationModels API accepts query parameters that narrow results server-side. You will filter by output modality, provider, and inference type to answer a specific question: which Amazon text models can I call on-demand?
- In main.go, add the bedrocktypes import to your import block by adding this line after the bedrock import:
bedrocktypes "github.com/aws/aws-sdk-go-v2/service/bedrock/types"
- Below your main() function, add the discoverModels function that filters for Amazon text models:
func discoverModels(ctx context.Context, client *bedrock.Client) {
textModality := "TEXT"
provider := "Amazon"
inferenceType := "ON_DEMAND"
result, err := client.ListFoundationModels(ctx, &bedrock.ListFoundationModelsInput{
ByOutputModality: bedrocktypes.ModelModality(textModality),
ByProvider: &provider,
ByInferenceType: bedrocktypes.InferenceType(inferenceType),
})
if err != nil {
log.Fatalf("ListFoundationModels failed: %v", err)
}
fmt.Printf("Amazon TEXT models (ON_DEMAND): %d found\n", len(result.ModelSummaries))
for i, m := range result.ModelSummaries {
if i >= 10 {
fmt.Printf(" ... and %d more\n", len(result.ModelSummaries)-10)
break
}
fmt.Printf(" %s | streaming=%v | customizations=%v\n",
*m.ModelId, m.ResponseStreamingSupported, m.CustomizationsSupported)
}
}
What does this code do?
The three filter variables (TEXT, Amazon, ON_DEMAND) narrow the catalog server-side so you only receive models matching all three criteria.
The bedrocktypes.ModelModality and bedrocktypes.InferenceType type conversions satisfy the SDK's typed enum parameters. For each result, you print the model ID, whether it supports streaming, and what customization options it offers (fine-tuning, distillation, etc.).
- Save main.go. You will connect this function to main() in the next chunk.
Connect the discovery function to main
Your discoverModels function exists but nothing calls it yet. You also need to introduce the runtime client now so the architectural separation is visible in code.
- Add the bedrockruntime import to your import block:
"github.com/aws/aws-sdk-go-v2/service/bedrockruntime"
- Replace your entire main() function with this version that creates both clients and calls discoverModels:
func main() {
ctx := context.Background()
cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(region))
if err != nil {
log.Fatalf("Failed to load AWS config: %v", err)
}
controlClient := bedrock.NewFromConfig(cfg)
_ = bedrockruntime.NewFromConfig(cfg)
fmt.Println("=== STEP 2: Control Plane Discovery ===")
discoverModels(ctx, controlClient)
}
Why two clients?
The bedrock.Client talks to the control plane (model catalog, management). The bedrockruntime.Client talks to the data plane (inference). They hit different service endpoints at the network level.
The blank identifier _ = satisfies Go's unused-import rule. You will use the runtime client for inference calls in the next step.
- Save main.go and run your program:
go run main.go
You should see a header line followed by Amazon text models with their IDs, streaming support, and customization options listed.
Seeing an import error?
If you see imported and not used, confirm that your main() includes the _ = bedrockruntime.NewFromConfig(cfg) line and that discoverModels references bedrocktypes.
If you see undefined: discoverModels, make sure the function is defined outside of main() (after the closing brace of main, not nested inside it).
Help me fix my import or undefined error in main.go
Compare against fine-tunable models
Filtering by inference type shows what you can call right now. Filtering by customization type shows what you could train on your own data. Comparing the two answers a strategic question: which of the models I can invoke today could I also fine-tune later?
- In main.go, add the following code at the end of your discoverModels function, just before the closing }:
// Second query: fine-tunable models
ftResult, err := client.ListFoundationModels(ctx, &bedrock.ListFoundationModelsInput{
ByCustomizationType: bedrocktypes.ModelCustomization("FINE_TUNING"),
})
if err != nil {
log.Fatalf("ListFoundationModels (fine-tuning) failed: %v", err)
}
fmt.Printf("\nFine-tunable models: %d\n", len(ftResult.ModelSummaries))
// Architecture note: bedrock.Client = control plane (discovery, management)
// bedrockruntime.Client = data plane (inference). Different endpoints.
What does this query reveal?
The ByCustomizationType filter asks: of all foundation models, which ones support fine-tuning? The result count tells you how many models you could retrain with your own dataset.
The architecture comments document a key insight: bedrock.Client handles discovery and management while bedrockruntime.Client handles inference. They are different service endpoints, not just different method sets on the same client.
- Save main.go.
- Before you run this, predict: will the fine-tunable model count be larger or smaller than the Amazon TEXT on-demand count?
go run main.go
You should see your filtered Amazon TEXT models listed with their streaming and customization details, followed by the total count of fine-tunable models across all providers.
Getting a re-declaration error?
If you see err redeclared in this block, make sure the second ListFoundationModels call uses ftResult, err := (not result, err :=). Go allows re-assigning err with := as long as at least one variable on the left side is new.
Help me fix a redeclaration error in my discoverModels function
✔️ Awesome, I've got everything!
Great. Double check you have saved main.go and your output shows both the filtered model list and the fine-tunable count.
ⓧ I'd like to double check the full code
Here is the complete main.go at this point. Confirm yours matches:
Your file has three sections:
- The import block with config, bedrock, bedrocktypes, and bedrockruntime.
- A main() function that creates both clients and calls discoverModels.
- A discoverModels function with the Amazon TEXT query, a loop printing up to 10 results, the fine-tuning query, and the architecture comments.
Scroll up and confirm each section is present and in order. If you need the exact source, compare against the chunks above.
You now know how to programmatically answer "what can I build with?" before writing any inference code. Next up, you will use the runtime client to invoke a model directly with a hand-crafted JSON payload.
Invoke a Model Directly
You can now discover what models exist, but discovering is not using. The InvokeModel API gives you raw, direct access to a foundation model's inference endpoint.
This is how Bedrock originally worked: you hand-craft a JSON payload that matches the target model's exact schema, send it to the runtime client, and parse the model-specific response yourself. In this step, you will build that raw invocation against Amazon Nova Lite and experience what full low-level control feels like.
In this step, get ready to:
- Construct a model-specific JSON request payload for Amazon Nova Lite.
- Call InvokeModel through the runtime client and parse the raw response.
- Observe how much model-specific knowledge this approach requires.
Update your imports and wire up the runtime client
Your main.go already creates a bedrockruntime.Client but discards it with _. You need to keep that client and add two new imports for JSON marshaling and the aws helper package.
- In main.go, find the import block at the top of the file.
- Find this line:
"fmt"
- Add "encoding/json" directly above it, so the import block now includes both:
"encoding/json"
"fmt"
- Still in the import block, find this line:
"github.com/aws/aws-sdk-go-v2/config"
- Add "github.com/aws/aws-sdk-go-v2/aws" directly above it, so you have:
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
- Now find this line in your main() function:
_ = bedrockruntime.NewFromConfig(cfg)
- Replace it with this, which keeps the runtime client for use:
runtimeClient := bedrockruntime.NewFromConfig(cfg)
- Finally, add these two lines at the bottom of your main() function, after the discoverModels call:
fmt.Println("\n=== STEP 3: InvokeModel (Raw, Model-Specific) ===")
invokeNovaRaw(ctx, runtimeClient)
Why two separate clients?
The bedrock.Client (control plane) talks to the management API for discovery and configuration. The bedrockruntime.Client (data plane) talks to the inference API that actually runs prompts through models. They hit different AWS service endpoints under the hood.
Define the request and response structs
Amazon Nova expects a specific JSON structure: a messages array where each message has a role and content array, plus an inferenceConfig object with parameters like maxTokens and temperature. You need Go structs that marshal into this exact shape.
- Add these struct definitions below your discoverModels function in main.go:
type NovaRequest struct {
Messages []NovaMessage `json:"messages"`
InferenceConfig NovaInfConfig `json:"inferenceConfig"`
}
type NovaMessage struct {
Role string `json:"role"`
Content []NovaContent `json:"content"`
}
type NovaContent struct {
Text string `json:"text"`
}
type NovaInfConfig struct {
MaxTokens int `json:"maxTokens"`
Temperature float64 `json:"temperature"`
}
type NovaResponse struct {
Output struct {
Message struct {
Role string `json:"role"`
Content []struct {
Text string `json:"text"`
} `json:"content"`
} `json:"message"`
} `json:"output"`
}
What do these structs do?
- NovaRequest mirrors Nova's top-level JSON shape with messages and inferenceConfig fields.
- NovaMessage and NovaContent model the nested message structure: each message has a role and an array of content blocks.
- NovaInfConfig holds generation parameters. Nova supports maxTokens up to 5000 and temperature between 0 and 1.
- NovaResponse matches Nova's output shape so you can unmarshal the raw JSON body into typed Go data.
Write the invokeNovaRaw function
Now you will write the function that builds the request, calls InvokeModel, and prints the response. This function uses the bedrockruntime.Client to send your hand-crafted JSON directly to the model.
- Add this function below your struct definitions in main.go:
func invokeNovaRaw(ctx context.Context, client *bedrockruntime.Client) {
reqBody := NovaRequest{
Messages: []NovaMessage{
{
Role: "user",
Content: []NovaContent{{Text: "What is Amazon Bedrock in one sentence?"}},
},
},
InferenceConfig: NovaInfConfig{
MaxTokens: 200,
Temperature: 0.7,
},
}
body, err := json.Marshal(reqBody)
if err != nil {
log.Fatalf("Failed to marshal request: %v", err)
}
output, err := client.InvokeModel(ctx, &bedrockruntime.InvokeModelInput{
ModelId: aws.String("amazon.nova-lite-v1:0"),
ContentType: aws.String("application/json"),
Body: body,
})
if err != nil {
log.Fatalf("InvokeModel failed: %v", err)
}
var resp NovaResponse
if err := json.Unmarshal(output.Body, &resp); err != nil {
log.Fatalf("Failed to unmarshal response: %v", err)
}
if len(resp.Output.Message.Content) > 0 {
fmt.Printf("[Nova via InvokeModel]: %s\n", resp.Output.Message.Content[0].Text)
}
}
What does this function do?
- It builds a NovaRequest struct with a user message and inference config, then marshals it to JSON bytes.
- It passes those bytes as the Body of InvokeModelInput, along with the model ID amazon.nova-lite-v1:0 and content type application/json.
- The response comes back as raw JSON bytes in output.Body. You unmarshal it into NovaResponse and extract the assistant's text.
- Notice: you had to know Nova's exact field names (messages, inferenceConfig, maxTokens), its response structure (output.message.content), and the content type. None of this transfers to other models.
- Save main.go.
Before you run this, what do you expect to see? The program should print the control plane discovery output from before, then a one-sentence answer from Nova about what Bedrock is.
- Run your program:
go run main.go
After the model discovery output, you should see a line starting with [Nova via InvokeModel]: followed by Nova's one-sentence description of Amazon Bedrock.
Seeing an AccessDeniedException?
- Confirm your IAM user or role has the bedrock:InvokeModel permission. The control plane worked because ListFoundationModels uses a different permission.
- Verify you are running in us-east-1 where Nova Lite is available on-demand.
- If you see a model-not-found error, confirm the model ID is exactly amazon.nova-lite-v1:0 with no typos.
help me debug an AccessDeniedException from Bedrock InvokeModel in Go
What you had to know to make this work
To call a single model via InvokeModel, you needed: the exact JSON field names for Nova's request format, the nested response structure, the correct content type header, and the precise model ID string.
Every piece of that knowledge is specific to Amazon Nova. If you wanted to call Anthropic Claude next, you would need a completely different set of structs, field names, and response parsing. That limitation is exactly what the next step explores.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.go and your output includes both the discovery section and the InvokeModel response.
ⓧ I'd like to double check the full code
Your main.go should now contain these sections in order. Scroll through your file and confirm each is present:
- Import block with encoding/json, fmt, log, aws, config, bedrock, bedrocktypes, and bedrockruntime.
- The main() function creating both controlClient and runtimeClient, calling discoverModels then invokeNovaRaw.
- The discoverModels function (unchanged from the previous step).
- Five struct definitions: NovaRequest, NovaMessage, NovaContent, NovaInfConfig, NovaResponse.
- The invokeNovaRaw function building the request, calling InvokeModel, and printing the response.
You now have raw, low-level model access working. Next up, you will try swapping that same code to a different provider and discover exactly why Bedrock needed a higher-level abstraction.
Break It, Then Fix It With Converse
You now have a working InvokeModel call that talks to Amazon Nova Lite. The JSON payload you built is hand-crafted to Nova's specification. But what happens when you want to use a different provider's model?
In this step, you will try invoking Anthropic Claude with your Nova payload, watch it fail, and then rebuild with the Converse API that makes the problem disappear.
In this step, get ready to:
- Break your InvokeModel call by swapping the model ID to Claude.
- Write a model-agnostic function using the Converse API.
- Prove identical code works for both Nova and Claude with zero changes.
Swap the model ID and watch it break
Your invokeNovaRaw function sends a NovaRequest struct with fields like messages and inferenceConfig. Claude expects completely different field names and an entirely different JSON structure. Let's prove this.
- In main.go, scroll to your invokeNovaRaw function and find this line:
ModelId: aws.String("amazon.nova-lite-v1:0"),
- Replace the model ID so the line now reads:
ModelId: aws.String("anthropic.claude-3-haiku-20240307-v1:0"),
- Save main.go.
Before you run this, ask yourself: do you think Claude will accept a payload designed for Nova? What do you expect to see?
- Run your program:
go run main.go
The discovery section runs fine, but your InvokeModel call crashes with a validation error. The payload you built matches Nova's JSON schema, not Claude's.
Why did this fail?
Each foundation model on Bedrock defines its own request and response JSON format at the InvokeModel level. Nova expects messages with inferenceConfig. Claude expects anthropic_version, max_tokens, and a different content structure.
This means every time you switch models with InvokeModel, you need new structs, new marshalling logic, and new response parsing. That knowledge is non-transferable between providers.
This is the exact problem Bedrock's Converse API was built to solve. Now revert the model ID before building the fix.
- In main.go, find the model ID you just changed and revert it back to:
ModelId: aws.String("amazon.nova-lite-v1:0"),
- Save main.go.
Write the model-agnostic Converse function
The Converse API normalizes the interface across all Bedrock models. You pass a standard Message type with a standard ContentBlock regardless of which provider you are calling.
- In your import block at the top of main.go, add the bedrockruntime types package below the existing bedrockruntime import:
"github.com/aws/aws-sdk-go-v2/service/bedrockruntime/types"
- At the bottom of main.go, add the first half of the converseModelAgnostic function. This sets up the models, builds a message, and makes the Converse call:
func converseModelAgnostic(ctx context.Context, client *bedrockruntime.Client) {
// Same prompt sent to both models proves portability
models := []string{
"amazon.nova-lite-v1:0",
"anthropic.claude-3-haiku-20240307-v1:0",
}
prompt := "What is Amazon Bedrock in one sentence?"
for _, modelId := range models {
// Build a message using the standard Converse types
message := types.Message{
Role: types.ConversationRoleUser,
Content: []types.ContentBlock{
&types.ContentBlockMemberText{Value: prompt},
},
}
// Identical call structure regardless of provider
result, err := client.Converse(ctx, &bedrockruntime.ConverseInput{
ModelId: aws.String(modelId),
Messages: []types.Message{message},
})
if err != nil {
log.Fatalf("Converse failed for %s: %v", modelId, err)
}
What does this code do?
- The models slice holds two model IDs from different providers. The loop calls each with the same code path.
- types.Message and types.ContentBlockMemberText are Bedrock's normalized types. They work for any model that supports Converse.
- The ConverseInput struct is provider-agnostic. You set ModelId and Messages the same way for Nova, Claude, or any supported model.
- Directly below the error check (still inside the for loop), add the response extraction code that closes the function:
// Extract response via type assertions (same for all models)
responseMsg, ok := result.Output.(*types.ConverseOutputMemberMessage)
if !ok {
log.Fatalf("Unexpected output type for %s", modelId)
}
text, ok := responseMsg.Value.Content[0].(*types.ContentBlockMemberText)
if !ok {
log.Fatalf("Unexpected content type for %s", modelId)
}
fmt.Printf("[%s]: %s\n", modelId, text.Value)
}
}
What does this code do?
- result.Output is an interface. You use a Go type assertion to extract the concrete *types.ConverseOutputMemberMessage type from it.
- The second type assertion extracts *types.ContentBlockMemberText from the response content. This gives you the model's text output.
- These type assertions are identical regardless of whether the response came from Nova or Claude. The Converse API normalizes the output structure.
- Now wire it into main(). Find the last line of your main function (after the invokeNovaRaw call) and add:
fmt.Println("\n=== STEP 4: Converse API (Model-Agnostic) ===")
converseModelAgnostic(ctx, runtimeClient)
- Save main.go.
Run and see both models respond with identical code
Before you run this, think about what you expect. The same function is calling Nova and Claude. No struct changes, no custom JSON, no separate parsing logic. Will both respond?
- Run your program:
go run main.go
Under the STEP 4 header, you see responses from both models printed with their model IDs. Same calling code, two different providers, two valid answers.
What just happened?
You went from a hand-crafted Nova-specific payload (which crashed when pointed at Claude) to a single function that calls any model on Bedrock. This is the Converse API's value: write once, run on any model.
Under the hood, Bedrock translates your normalized ConverseInput into whatever format each model expects. You never touch model-specific JSON again.
Converse call failing for Claude?
- Make sure you submitted the one-time Anthropic usage form in the Bedrock console. Without it, Claude calls return an access denied error.
- Check that the new types import is github.com/aws/aws-sdk-go-v2/service/bedrockruntime/types (not the bedrocktypes alias you used for the control plane).
- Confirm the model ID is exactly anthropic.claude-3-haiku-20240307-v1:0 with no typos.
Still stuck? Help me debug my Converse API call to Anthropic Claude on Bedrock.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.go and both Nova and Claude responded under the STEP 4 header.
ⓧ I'd like to double check the full code
Your main.go should now have these sections in order. Scroll through and confirm each is present:
- Import block with both bedrockruntime and bedrockruntime/types.
- main() calling discoverModels, invokeNovaRaw, and converseModelAgnostic.
- discoverModels function (unchanged from Step 2).
- Nova struct types and invokeNovaRaw function with model ID back at amazon.nova-lite-v1:0.
- converseModelAgnostic function iterating over both model IDs, building a types.Message, calling Converse, and printing each response.
You have proven the architectural gap that the Converse API closes. Next, you will add real-time streaming so tokens arrive one by one instead of waiting for the full response.
Stream Responses in Real Time
Your Converse API code already proves model portability. Both Nova and Claude respond to the same function call with zero changes.
In production, waiting for the entire response before showing anything creates a poor user experience. ConverseStream solves this by delivering tokens as they're generated through an event stream. You'll build a streaming function that uses Go's channel-based pattern from the AWS SDK v2.
In this step, get ready to:
- Write a reusable streaming function using the ConverseStream API.
- Handle event stream types with a type switch to print tokens in real time.
- Stream responses from both Amazon Nova and Anthropic Claude using a cross-region inference profile.
Write the streaming function shell
The ConverseStream API works like Converse but returns an event stream instead of a complete response. Your function takes a model ID and prompt as parameters, making it reusable across providers.
- In main.go, add the following function below your converseModelAgnostic function. This sets up the streaming connection and obtains the event channel:
func streamConverse(ctx context.Context, client *bedrockruntime.Client, modelId string, prompt string) {
message := types.Message{
Role: types.ConversationRoleUser,
Content: []types.ContentBlock{
&types.ContentBlockMemberText{Value: prompt},
},
}
output, err := client.ConverseStream(ctx, &bedrockruntime.ConverseStreamInput{
ModelId: aws.String(modelId),
Messages: []types.Message{message},
})
if err != nil {
log.Fatalf("ConverseStream failed for %s: %v", modelId, err)
}
stream := output.GetStream()
defer stream.Close()
fmt.Printf("[%s streaming]: ", modelId)
What does this code do?
- client.ConverseStream initiates a streaming inference call. The request shape is identical to Converse but the response is an event stream.
- output.GetStream() returns a channel-based stream you can iterate over.
- defer stream.Close() ensures the stream is properly closed when the function returns, freeing the HTTP connection.
Handle the streaming events
The stream delivers typed events as tokens arrive. You process them with a for range loop and a type switch, which is the same pattern used across all streaming AWS services in Go.
- First, add "os" to your import block (after "log"). The default case in the event loop references os.Stderr.
- Then, still inside the streamConverse function, add this event loop directly below the fmt.Printf line you just wrote:
for event := range stream.Events() {
switch v := event.(type) {
case *types.ConverseStreamOutputMemberContentBlockDelta:
if delta, ok := v.Value.Delta.(*types.ContentBlockDeltaMemberText); ok {
fmt.Print(delta.Value)
}
case *types.ConverseStreamOutputMemberMetadata:
if v.Value.Usage != nil {
fmt.Printf("\n [tokens: input=%d, output=%d]\n",
*v.Value.Usage.InputTokens, *v.Value.Usage.OutputTokens)
}
case *types.ConverseStreamOutputMemberMessageStart:
// Stream started
case *types.ConverseStreamOutputMemberMessageStop:
// Stream ended
case *types.ConverseStreamOutputMemberContentBlockStop:
// Content block ended
default:
fmt.Fprintf(os.Stderr, "Unknown event type: %T\n", event)
}
}
if err := stream.Err(); err != nil {
log.Fatalf("Stream error for %s: %v", modelId, err)
}
}
What does this event loop do?
- ContentBlockDelta events carry text fragments. You extract the text via a nested type assertion and print it immediately with no newline. This produces the token-by-token effect.
- Metadata arrives after the stream ends and contains usage statistics (input/output token counts).
- MessageStart, MessageStop, and ContentBlockStop are lifecycle signals. You acknowledge them in the switch so Go doesn't hit the default case.
- stream.Err() checks for errors that occurred during streaming after the loop exits.
- Save main.go and confirm the code compiles by running:
go build .
You should see no output. A clean return means the code compiled successfully.
Seeing a compile error?
- If you see an error about os being undefined, confirm you added "os" to your import block at the top of main.go.
- If you see a syntax error about an unclosed brace, make sure the closing } of the streamConverse function is present at the end.
Help me fix a compile error in my streamConverse function.
Stream from both models with cross-region routing
Bedrock supports geographic cross-region inference profiles. Prefixing a model ID with us. tells Bedrock to route your request across US regions for higher availability. You'll call streamConverse with Nova directly and Claude via the us. profile.
- In your main() function, find the last line:
converseModelAgnostic(ctx, runtimeClient)
- Add the following lines directly below it:
fmt.Println("\n=== STEP 5: ConverseStream (Streaming) ===")
streamConverse(ctx, runtimeClient, "amazon.nova-lite-v1:0", "Explain what makes Go's concurrency model unique, in two sentences.")
fmt.Println()
streamConverse(ctx, runtimeClient, "us.anthropic.claude-3-haiku-20240307-v1:0", "Explain what makes Go's concurrency model unique, in two sentences.")
What is the us. prefix doing?
The model ID us.anthropic.claude-3-haiku-20240307-v1:0 is a geographic cross-region inference profile. It routes your request across US-based AWS regions (like us-east-1, us-east-2, us-west-2) for better throughput and availability.
The first call uses the direct model ID amazon.nova-lite-v1:0 (in-region only). The second uses the cross-region profile. Both stream through the exact same streamConverse function with no code changes.
Before you run this, what do you think the output will look like? Will the response appear all at once or character by character?
- Save main.go and run your program:
go run main.go
Watch the Step 5 section of the output. You'll see text appearing token-by-token in your terminal, followed by a usage summary for each model.
The streaming output looks something like this (the exact words will vary):
=== STEP 5: ConverseStream (Streaming) ===
[amazon.nova-lite-v1:0 streaming]: Go uses goroutines, which are lightweight...
[tokens: input=19, output=42]
[us.anthropic.claude-3-haiku-20240307-v1:0 streaming]: Go's concurrency model is built around...
[tokens: input=19, output=38]
Why does streaming matter?
In a chat application, streaming gives immediate feedback. Without it, users stare at a blank screen for seconds while the model generates the full response.
Notice that both models stream through the same code path. The ConverseStream API maintains the same model-agnostic interface you built in the previous step. Swapping models is still just swapping a string.
Not seeing streaming output?
- If you see an access denied error for the us. profile, your IAM permissions may not include cross-region inference. Try replacing us.anthropic.claude-3-haiku-20240307-v1:0 with anthropic.claude-3-haiku-20240307-v1:0 temporarily to confirm basic streaming works.
- If you see a throttling error, Bedrock has per-model rate limits. Wait 30 seconds and retry.
Help me debug my ConverseStream output.
✔️ Awesome, I've got everything!
Great. Double check you've saved main.go and that your streaming output shows token counts for both models.
ⓧ I'd like to double check the full code
Your main.go is now over 30 lines, so here's a structural check. Scroll through your file and confirm these sections exist in order:
- Import block includes "os" alongside "context", "encoding/json", "fmt", and "log".
- main() calls discoverModels, invokeNovaRaw, converseModelAgnostic, then two streamConverse calls (Nova and the us. Claude profile).
- discoverModels function with filtered queries and fine-tuning check.
- Nova request/response structs and invokeNovaRaw function.
- converseModelAgnostic function calling both Nova and Claude.
- streamConverse function with ConverseStream call, GetStream(), defer stream.Close(), event loop with type switch handling 5 event types, and stream.Err() check.
You've proven that Bedrock's streaming works identically across providers. Your streamConverse function handles both Amazon Nova and Anthropic Claude with real-time token delivery and usage reporting. Next up, you'll take this even further in the Secret Mission by giving the model access to local functions through tool use.
Secret mission
Tool Use - Function Calling Through Converse
You've proven you can talk to models. Now give a model the ability to talk back to your code. Implement tool use (function calling) through the Converse API so the model can request a local weather lookup function, receive the result, and synthesize a final answer.
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 runs entirely locally with on-demand Bedrock inference, so there are no ongoing costs. Your total spend for the inference calls you already made should be well under $1.
Resources you used:
- Local bedrock-explorer project directory (Go source code and module cache).
- Amazon Bedrock on-demand inference calls (already billed per-token, no persistent state created).
Keep everything running
No action needed. Choose this if you want to keep experimenting with your Bedrock explorer code.
- Your bedrock-explorer folder stays on your Desktop with all your Go code intact.
- Bedrock on-demand inference creates no persistent cloud resources. There is nothing running in AWS that could accumulate charges.
- You can re-run go run main.go any time to make new inference calls (each call costs fractions of a cent).
Pause - I'll come back to this later
There is nothing to pause. Bedrock on-demand inference has no running processes or provisioned resources.
- Your local bedrock-explorer folder remains on your Desktop. No action required.
- When you return, simply cd into the folder and run go run main.go again.
Delete - I don't want to use this again
Remove your local project files. There are no cloud resources to delete.
- Delete the project directory by running this command (macOS/Linux):
rm -rf ~/Desktop/bedrock-explorer
- On Windows (PowerShell), run:
Remove-Item -Recurse -Force ~\Desktop\bedrock-explorer
- Confirm the folder is gone by running:
ls ~/Desktop/bedrock-explorer
- You should see a "No such file or directory" message.
- No AWS resources need deletion. Bedrock on-demand inference creates no persistent state in your account.
Nice Work!
Nice Work!
Nice work! You just built a Go CLI tool that systematically explores Amazon Bedrock's architecture from the inside out.
You've learned how to:
- Query Bedrock's control plane to discover and filter foundation models by provider, modality, and inference type, then invoke a model directly via InvokeModel with hand-crafted JSON payloads.
- Hit the exact limitation that motivated the Converse API: model-specific payloads are non-portable across providers. Then rebuild with model-agnostic inference that swaps between Amazon Nova and Anthropic Claude with zero code changes.
- Stream token-by-token responses via ConverseStream with proper event loop handling, usage reporting, and cross-region inference profiles that route requests across AWS regions transparently.
- Secret Mission: Implemented the tool use loop, giving a foundation model access to local functions through the Converse API's native function calling mechanism.
Ready to quiz yourself?