Models & Guardrails
Bedrock and beyond, with safety that spans providers
- Configure a Bedrock model in agent code with BedrockModel(model_id, region_name, temperature), including cross-region inference profiles prefixed us. and global.
- Swap to non-Bedrock providers (OpenAI, Anthropic API, Ollama, LiteLLM for Gemini and 100+ others) by changing only the model object
- Diagnose AccessDeniedException on first run as missing per-model, per-region Bedrock model access — not a code bug
- Attach native Bedrock Guardrails to a BedrockModel and detect a block via stop_reason == 'guardrail_intervened'
- Apply the model-agnostic ApplyGuardrail API to moderate text for OpenAI/Gemini/self-hosted models, and run guardrails in shadow mode with Strands hooks
- Explain why API keys belong in AgentCore Identity, not in the container image or the LLM context
In AgentCore the model is not a platform setting — it is one line of configuration inside your agent code, which is why swapping Claude for GPT, Gemini, or a local Ollama model rarely touches anything else. This lesson shows how to wire Bedrock models (with cross-region inference profiles) and non-Bedrock providers through Strands provider classes, then how to add safety that spans every provider: native Bedrock Guardrails on a Bedrock model, and the model-agnostic ApplyGuardrail API plus shadow mode for everything else.
- 1The model is config, not platform
- 2Beyond Bedrock: Strands provider classes
- 3The first-run AccessDeniedException
- 4Native Bedrock Guardrails on a Bedrock model
- 5Model-agnostic safety: ApplyGuardrail + shadow mode
- 6Keep API keys out of the image
The model is config, not platform
AgentCore Runtime hosts a container that speaks a known protocol — it does not care which LLM your agent calls. That decision lives inside your agent code, as a model object you construct and hand to your framework. Change the object, change the model; the @app.entrypoint, the deploy flow, and the HTTP contract stay identical.
For Bedrock-hosted models, Strands gives you BedrockModel. The three arguments you'll set on day one are the model ID, the region, and the sampling temperature:
from strands import Agent
from strands.models.bedrock import BedrockModel
model = BedrockModel(
model_id="us.anthropic.claude-sonnet-4-5-20250929-v1:0", # see note on volatility
region_name="us-west-2",
temperature=0.3,
)
agent = Agent(model=model, tools=[...])That us. prefix is not cosmetic. It marks a cross-region inference profile: Bedrock routes the request across a set of regions in a geography to absorb load and improve availability, rather than pinning you to a single region's capacity. AgentCore-supported prefixes you'll see are:
| Prefix | Meaning |
|---|---|
| (none) | A model ID pinned to one region (e.g. amazon.nova-pro-v1:0) |
us. | US cross-region inference profile (routes within US regions) |
global. | Global inference profile (routes across geographies) |
Nova, Claude, Llama, and Mistral are all reachable this way. The point to internalize: picking a model is editing one constructor call, not reconfiguring the platform.
Watch out
Model IDs are volatile — pin and verify
Model IDs drift fast. As of writing the default Strands model is reported as global.anthropic.claude-sonnet-4-5-20250929-v1:0, and the older claude-sonnet-4-20250514 is already legacy. Do not memorize an ID as a timeless fact — pin a current one in config and verify it against the live Bedrock model catalog (linked in Resources). Teach the pattern (prefix + dated ID), not a specific number.
Beyond Bedrock: Strands provider classes
Because the model is just an object, you are not locked into Bedrock. Strands ships provider classes that all satisfy the same interface, so the only line that changes is which class you instantiate:
| Provider | Class | Import |
|---|---|---|
| Amazon Bedrock | BedrockModel | strands.models.bedrock |
| OpenAI | OpenAIModel | strands.models.openai |
| Anthropic API (direct) | AnthropicModel | strands.models.anthropic |
| Ollama (local) | OllamaModel | strands.models.ollama |
| LiteLLM (100+ incl. Gemini) | LiteLLMModel | strands.models.litellm |
| Custom | your subclass | — |
For example, the same agent on OpenAI or on Gemini-via-LiteLLM:
from strands import Agent
from strands.models.openai import OpenAIModel
from strands.models.litellm import LiteLLMModel
# OpenAI directly
openai_model = OpenAIModel(model_id="gpt-4o", client_args={"api_key": OPENAI_API_KEY})
# Gemini (and 100+ others) through LiteLLM
gemini_model = LiteLLMModel(model_id="gemini/gemini-1.5-pro")
agent = Agent(model=openai_model, tools=[...]) # swap to gemini_model freelyLiteLLMModel is the escape hatch: it fronts 100+ providers, including Google Gemini, behind one class, so you can reach a model Strands doesn't have a dedicated class for. OllamaModel points at a local Ollama server, which is handy for offline development against an open-weights model before you commit to a hosted one.
The architectural payoff is that model choice and agent logic are decoupled. Your tools, prompt, and entrypoint are written once; the provider is a swappable dependency.
Note
Provider lists drift too
The AWS-named framework and model lists (Strands/LangGraph/CrewAI/LlamaIndex/Google ADK/OpenAI Agents; Bedrock/OpenAI/Gemini/Anthropic/Nova/Llama/Mistral) grow over time. Treat the table above as the pattern and confirm current support against the AWS "using any agent framework" page in Resources.
The first-run AccessDeniedException
The single most common stumble on a brand-new agent is an AccessDeniedException on the very first model call. It looks like an IAM or code bug, but it usually is not.
Bedrock model access is granted per-model and per-region. Even with a valid execution role, a specific foundation model in a specific region is off until you explicitly enable access to it in that account/region. A model that works in us-east-1 may be denied in eu-central-1 because access was never enabled there.
The error surfaces from the SDK roughly like this:
AccessDeniedException: An error occurred (AccessDeniedException) when calling
the Converse operation: You don't have access to the model with the specified
model ID.Resolution checklist, in order:
- Enable model access for that exact model in that exact region (Bedrock console → Model access, or the equivalent API), and wait for it to show as granted.
- Confirm the model ID is current, not a legacy ID like
claude-sonnet-4-20250514. A retired ID can also read as denied. - Check the prefix matches your intent — a
us.inference profile must be enabled for the regions it routes across, not just your home region. - Only after the above, look at the execution role's Bedrock permissions.
Getting this ordering right saves hours: reach for model access before you start rewriting IAM policies.
Tip
Make the failure mode obvious in dev
When you onboard a new model or region, do a tiny smoke test (one agent("ping") call) before deploying. A fast, isolated AccessDeniedException at the model layer is far easier to read than the same error buried in a full agent loop running inside Runtime.
Native Bedrock Guardrails on a Bedrock model
Once a model is wired up, you want safety: content filters, denied topics, word filters, PII detection/redaction, and contextual grounding. Bedrock Guardrails provide all of these, and when your model is a Bedrock model, you attach a guardrail directly to the BedrockModel.
You create the guardrail once (in the Bedrock console or via API), which yields a guardrail_id and a guardrail_version. Then you pass those — plus optional trace and redaction controls — into the model object:
from strands import Agent
from strands.models.bedrock import BedrockModel
model = BedrockModel(
model_id="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
region_name="us-west-2",
guardrail_id="abcd1234efgh",
guardrail_version="1",
guardrail_trace="enabled", # include guardrail assessment in the trace
)
agent = Agent(model=model)
result = agent("...user prompt...")Now Bedrock evaluates every request and response against the guardrail inline with the model call. When a guardrail blocks the interaction, the model does not return normal content — it returns a distinct stop reason. Detect it by inspecting stop_reason:
if result.stop_reason == "guardrail_intervened":
# the guardrail blocked input or output; serve a safe fallback,
# log the event, and do NOT treat the (empty/blocked) content as an answer
return {"result": "I can't help with that request."}This is the clean path: the guardrail is part of the model's own request/response cycle, so there's no extra API call. The catch is that it only works because the model lives in Bedrock — which is exactly the limitation the next section removes.
Key insight
stop_reason is your enforcement signal
Treat stop_reason == "guardrail_intervened" as a first-class branch in your agent, not an edge case. The blocked response is not an answer — it's an event to log, count, and respond to with a safe fallback. Wiring this in early means your guardrail metrics and your fallback UX are already in place before you ever tighten a policy.
Model-agnostic safety: ApplyGuardrail + shadow mode
Native attachment only works on Bedrock models. But you may be running OpenAI, Gemini, or a self-hosted model — and you still want the same guardrail policy. The answer is the standalone ApplyGuardrail API, which moderates text without invoking a Bedrock foundation model at all. It's just a content-moderation call you can place around any provider's input and output.
import boto3
bedrock_runtime = boto3.client("bedrock-runtime", region_name="us-west-2")
# Moderate the user's INPUT before sending it to OpenAI/Gemini/etc.
resp = bedrock_runtime.apply_guardrail(
guardrailIdentifier="abcd1234efgh",
guardrailVersion="1",
source="INPUT", # or "OUTPUT" for the model's response
content=[{"text": {"text": user_prompt}}],
)
if resp["action"] == "GUARDRAIL_INTERVENED":
# blocked: substitute a safe message instead of calling the model
...Because ApplyGuardrail is a separate call, the pattern is: moderate the input → call your non-Bedrock model → moderate the output. The same guardrail (same content filters, denied topics, word filters, PII redaction, contextual grounding) now spans every provider you use.
Shadow mode lets you deploy a guardrail without enforcing it yet. Using Strands Hooks on MessageAddedEvent and AfterInvocationEvent, you call ApplyGuardrail and log what would have been blocked — without actually blocking it. You watch the would-be-block rate in production traffic, tune the policy, and only then flip to enforcement.
from strands.hooks import HookProvider, HookRegistry
from strands.hooks.events import MessageAddedEvent, AfterInvocationEvent
class ShadowGuardrail(HookProvider):
def register_hooks(self, registry: HookRegistry) -> None:
registry.add_callback(MessageAddedEvent, self._check_input)
registry.add_callback(AfterInvocationEvent, self._check_output)
def _check_input(self, event: MessageAddedEvent) -> None:
# call apply_guardrail(source="INPUT", ...) and LOG would-be blocks;
# do not raise / do not block in shadow mode
...
def _check_output(self, event: AfterInvocationEvent) -> None:
# call apply_guardrail(source="OUTPUT", ...) and LOG would-be blocks
...Example
Two ways, one policy
A team runs Claude on Bedrock for its main agent (native guardrail attached to BedrockModel, detected via stop_reason) and a Gemini-via-LiteLLM agent for a specialized task (same guardrail enforced via ApplyGuardrail around the call). One guardrail definition, two enforcement mechanisms — uniform safety regardless of where the model lives.
Tip
Roll out new policies in shadow first
Shadow mode turns guardrail tuning into a data exercise instead of a guessing game. Hook ApplyGuardrail onto MessageAddedEvent/AfterInvocationEvent, ship it logging-only, and read the would-be-block rate against real traffic for a few days before you enforce. You'll catch false positives that would otherwise block legitimate users on day one.
Keep API keys out of the image
Non-Bedrock providers need credentials — an OpenAI key, a Gemini key. The wrong move is baking them into the container image or passing them through the LLM context where they can leak. Keys belong in AgentCore Identity, injected at call time.
Instead of hardcoding, you register the key once with CreateApiKeyCredentialProvider (which stores it in AWS Secrets Manager — you can also reference an existing secret you already manage), then pull it in with a decorator at the moment of use. The raw key never touches your business logic or the model's prompt:
from bedrock_agentcore.identity.auth import requires_api_key
@requires_api_key(provider_name="openai-provider", into="api_key")
async def call_openai(prompt: str, *, api_key: str):
model = OpenAIModel(model_id="gpt-4o", client_args={"api_key": api_key})
agent = Agent(model=model)
return agent(prompt)For OAuth-protected resources (not just static keys), the sibling decorator @requires_access_token(provider_name, scopes, auth_flow, into=...) runs the full workload-identity → token-vault → consent flow and injects an access_token kwarg the same way. Both decorators live in bedrock_agentcore.identity.auth.
The deep mechanics of the token vault, OAuth 2LO/3LO, and per-provider refresh config are a later lesson on AgentCore Identity. For now, the rule is simple: secrets are injected, never embedded. If you find yourself reading an env var holding a provider key inside your image, route it through Identity instead.
Watch out
An LLM prompt is not a vault
Never put a provider API key anywhere the model can see it — system prompt, tool args, or context. A leaked key in context can be exfiltrated through prompt injection or surfaced in logs/traces. The @requires_api_key decorator exists precisely so the secret is resolved from Secrets Manager into a kwarg at call time and never enters the LLM's view.
Try it: Swap providers, then make safety span all of them
Goal: prove the model is just config, then enforce one guardrail policy across a Bedrock model and a non-Bedrock model.
- Stand up a Bedrock agent. In a Strands agent, build
model = BedrockModel(model_id="<current Claude or Nova ID>", region_name="<your region>", temperature=0.3)and run a singleagent("hello")smoke test. If you hitAccessDeniedException, do NOT touch IAM yet — enable model access for that exact model in that exact region, confirm the ID isn't legacy, and retry. Note what fixed it. - Swap the provider. Without changing your tools, prompt, or entrypoint, replace the model object with a non-Bedrock one —
OpenAIModel(model_id="gpt-4o", ...)orLiteLLMModel(model_id="gemini/gemini-1.5-pro"). Re-run the same smoke test. Confirm the ONLY line you changed was the model construction. - Create a guardrail. In the Bedrock console, create a guardrail with at least one denied topic and PII redaction enabled. Record its
guardrail_idandguardrail_version. - Native enforcement on Bedrock. Re-attach the Bedrock model, pass
guardrail_id,guardrail_version, andguardrail_trace="enabled". Send a prompt that hits your denied topic and assert thatresult.stop_reason == "guardrail_intervened". Branch to a safe fallback. - Model-agnostic enforcement. For the non-Bedrock model, wrap the call:
bedrock_runtime.apply_guardrail(guardrailIdentifier=..., guardrailVersion=..., source="INPUT", content=[{"text": {"text": prompt}}])before the model call andsource="OUTPUT"after. Send the same offending prompt and confirm you getaction == "GUARDRAIL_INTERVENED"from the same guardrail definition. - Shadow mode. Register a Strands
HookProviderthat calls ApplyGuardrail onMessageAddedEventandAfterInvocationEventbut only LOGS would-be blocks (no enforcement). Run a mix of safe and offending prompts and read your log of would-be blocks. - Secrets check. Confirm your OpenAI/Gemini key is NOT in the container, source, or any prompt — sketch how
@requires_api_key(provider_name=..., into="api_key")would inject it from AWS Secrets Manager instead. - Reflect. In three sentences: which line(s) changed when you swapped providers, why native vs ApplyGuardrail enforcement differ, and what shadow mode told you that a straight enforcement rollout would have hidden.
Key takeaways
- 1The model is configuration inside your agent: `BedrockModel(model_id, region_name, temperature)`. Swapping models is editing one constructor call, not reconfiguring the platform.
- 2Cross-region inference profiles are signaled by prefixes: `us.` (US routing) and `global.` (cross-geography). A bare ID (e.g. `amazon.nova-pro-v1:0`) is pinned to one region.
- 3Non-Bedrock providers plug in via interchangeable Strands classes — `OpenAIModel`, `AnthropicModel`, `OllamaModel`, and `LiteLLMModel` (100+ providers including Gemini). Only the model object changes.
- 4Model IDs are volatile: the default Strands model and `claude-sonnet-4-20250514` are already drifting. Pin a current ID in config and verify against the live Bedrock model catalog.
- 5A first-run `AccessDeniedException` almost always means Bedrock model access is not enabled for that specific model in that specific region — fix model access before touching IAM.
- 6Native Guardrails attach `guardrail_id`/`guardrail_version` to a `BedrockModel`; detect a block with `stop_reason == "guardrail_intervened"`. For non-Bedrock models, the `ApplyGuardrail` API moderates input/output text with no FM call, and Strands hooks (`MessageAddedEvent`/`AfterInvocationEvent`) enable shadow mode.
- 7Keep provider API keys out of the image and out of LLM context — register them with `CreateApiKeyCredentialProvider` (AWS Secrets Manager) and inject via AgentCore Identity's `@requires_api_key` / `@requires_access_token` decorators.
Quiz
Lock in what you learned
Check your understanding
0 / 4 answered
1.Your Strands agent uses `BedrockModel(model_id="us.anthropic.claude-sonnet-4-5-20250929-v1:0", region_name="us-west-2")`. What does the `us.` prefix indicate?
2.You need your agent to run on Google Gemini, but Strands has no `GeminiModel` class. What's the idiomatic way to do it?
3.A teammate's brand-new agent throws `AccessDeniedException` on its very first model call, even though the execution role has Bedrock permissions. What's the most likely cause and first fix?
4.Your main agent runs Claude on Bedrock with a native guardrail; a second agent runs Gemini via LiteLLM and must enforce the SAME safety policy. How do you apply guardrails to the Gemini agent?
Go deeper
Hand-picked sources to keep learning
Authoritative (volatile) list of supported frameworks and models — verify provider support against this page rather than memorizing it.
The live source of truth for model IDs and inference-profile prefixes. Model IDs are volatile; always pin a current one from here.
How to attach native guardrails to BedrockModel, detect stop_reason, and run shadow mode via Strands hooks.
The model-agnostic ApplyGuardrail call: moderate INPUT/OUTPUT text without invoking a foundation model, for any provider.
Conceptual overview of content filters, denied topics, word filters, PII detection/redaction, and contextual grounding.
Source for the provider classes (BedrockModel, OpenAIModel, AnthropicModel, OllamaModel, LiteLLMModel) and the hooks API.