AWS Builder Center

Best Practices for Building Agents with Amazon Bedrock AgentCore

Best practices for building secure, multi-tenant agents on Amazon Bedrock AgentCore. The guide explains what AWS guarantees, what you own, and the defaults that cause production incidents, with patterns, diagrams, and a design-review checklist.

Best Practices for Building Agents with Amazon Bedrock AgentCore

Introduction

Most introductions to Amazon Bedrock AgentCore stop at the happy path: here is Runtime, here is Memory, here is a Gateway, wire them together and ship. That gets you a demo. It does not get you a production system, because the questions that decide whether an agentic workload is secure, multi-tenant, and reliable live in the gaps between the services, and those gaps are exactly where the documentation is thinnest.
This article is the guide I wish I had when I moved from "I can build an AgentCore demo" to "I am accountable for an AgentCore workload in production." It walks through the eight building blocks you actually assemble, which is to say Harness, Runtime, Memory, Identity, Gateway, Policy, Browser, and Code Interpreter, and for each one it states the recommended practice, the reasoning behind it, and the non-obvious behavior that tends to surface only once real traffic hits.
Who this is for. Engineers and architects who have built an agent or two and are now asking the harder questions: How do I keep one tenant's data out of another tenant's session? Can I actually force a tool to run? Where do credentials live so the model can never leak them? What breaks at a session boundary, a token boundary, or a Region boundary?
What you will gain. By the end you will be able to reason about an AgentCore architecture the way a reviewer does: you will know which guarantees AWS gives you and which ones you are responsible for wiring, you will recognize the handful of defaults that quietly cause production incidents, and you will have concrete patterns, code, and a one-page caveats cheat sheet you can take straight into a design review. Each section is self-contained, so you can read start to finish or jump to the capability you are designing right now.
A note on how to read this: every section states the recommended practice and the reasoning behind it. Region, quota, and API specifics evolve, so confirm current values in the AWS documentation when you implement.

What this guide covers





1. Harness

A managed agent loop: declare model + system prompt + tools + memory in JSON, invoke with one API call. AWS runs orchestration, tool execution, and memory management. Each session runs in its own isolated microVM.

Knowing when to outgrow Harness

Harness is the declarative paved path, ideal when your agent is model + tools + memory + prompt. You drop to a custom Strands/LangGraph runtime when you need control flow the loop can't express, in-process multi-agent orchestration, or logic between tool calls. Escape hatch:  agentcore export harness  converts a Harness into a deployable Strands agent, so you start declarative and graduate to code without a rewrite.

Forcing a specific tool call

Harness does not expose a  toolChoice  lever, so you cannot force a specific tool (such as Code Interpreter) from harness config. Enforce tool use around Harness, as a layered ladder:
  1. Prompt insistence: tell the model it must run the tool. Necessary, not sufficient.
  1. Output-contract shaping: have the UI/result render only from real tool stdout, so a bypass can't fake it.
  1. Backend detect-and-retry: if a reply states a computed result with no corresponding tool call that turn, reject and force one retry. This is the practical enforcement.
  1. Know the ceiling: the model can still bypass on both attempts. For a hard guarantee, move tool-forcing out of Harness (next note).
💡 Production hardening: Export to a Strands runtime → own the  converse()  loop → set Bedrock  toolChoice  (Lever A) and a structural gate that refuses a final answer until a tool result exists (Lever B). Cleanest of all: make the computation backend-authoritative so the model never gets to skip it.
Forced- toolChoice  skeleton (exported Strands agent):
Python
# Lever A: force the tool call (Harness cannot do this; a custom loop can)
resp = bedrock.converse(
modelId=MODEL_ID, messages=messages,
toolConfig={**TOOL_SPEC,
# {"tool":{"name":...}} MUST call it | {"any":{}} some tool | {"auto":{}} model decides
"toolChoice": {"tool": {"name": "run_computation"}}},
)

# Lever B: structural gate: no tool result => no answer (no model trust needed)
tool_use = next((c["toolUse"] for c in msg["content"] if "toolUse" in c), None)
if tool_use is None:
raise ToolNotExecuted("model did not call the required tool")
result = run_in_sandbox(tool_use["input"]) # real tool stdout = proof it ran

Native vs custom tools & the  @  prefix

Reference
Meaning
Examples
bare name
AWS built-in tool
 code_interpreter ,  browser ,  x_amz_bedrock_agentcore_search ,  file_operations ,  memory 
 @ -reference
Anything you brought, i.e. Gateway tools and inline functions
 @my-gateway/* ; inline functions declared in the harness
⚠️ Gotcha:  @  does NOT mean "Gateway." It means "not an AWS built-in." Custom ≠ Gateway-only: Gateway is for real backend services; inline functions are custom too and need no Gateway. A bare name that isn't a recognized built-in is silently rejected →  Unknown tool .

Custom container image

Build type lives on the agent runtime spec ( AgentEnvSpec ) in  agentcore.json , not on  harness.json  (harnesses are  CodeZip  by nature).  BuildType: 'Container'  → a Dockerfile in the agent's  codeLocation , built in CodeBuild (ARM64), pushed to a per-agent ECR repo. Reach for it only when the managed  CodeZip  environment lacks a dependency you need in-process (e.g. a native numeric library).
Key takeaway: Harness = "AWS wrote the loop for you" (CodeZip, no toolChoice). Need to force a tool or bake a dependency? Export to a runtime agent: that's where  toolChoice  and  BuildType: Container  live.




2. Runtime & session isolation

Serverless, session-isolated compute. Each session gets its own microVM; on teardown the microVM is terminated and memory sanitized.
Multi-tenant isolation stack:


AWS guarantees only the bottom layer on its own. You wire the three above it.

The session model

  • Sticky via a protocol header: MCP →  Mcp-Session-Id ; HTTP/A2A/AG-UI →  X-Amzn-Bedrock-AgentCore-Runtime-Session-Id . Capture & resend or every turn risks a new cold microVM.
  • Ephemeral compute: in-session state lives only for the compute lifecycle. Durable structured state → Memory; files across stop/resume → session storage.
  • Limits: idle default 15 min; max lifetime 8 h on microVMs (up to 14 days on Instances). Stopped → resumes Active on next invoke with fresh compute.
  • 409 RetryableConflictException during provision/teardown: retry with backoff.
⚠️ The caveat to say out loud: AgentCore isolates the microVM but does not enforce session-to-user mapping. Your backend owns the  sub → sessionId  binding, so verify a requested session id belongs to the authenticated  sub  before resuming. Getting this wrong is the real multi-tenant bug, not the compute boundary.

How the backend API maintains the user → session mapping

Because AgentCore won't bind users to sessions for you, the backend API is the component that owns it. The mapping is not just bookkeeping for continuity; it is the multi-tenant boundary: it decides that a given microVM session is only ever driven on behalf of the one authenticated user it belongs to.
The flow on every request:
  1. Validate the token, extract the  sub . The UI calls the backend with the user's credentials; the IdP (Cognito, Okta, Entra ID, Auth0, …) validates and returns a JWT. The backend reads the  sub  claim from the validated token, never from a client-supplied body field or header, or you've built an impersonation hole.
  1. Look up (or mint) the session id. The backend queries a persistent store (e.g. DynamoDB) keyed by  sub . If the user has an open conversation, it reuses that  sessionId ; if not, it mints a new one (≥ 33 chars) and writes the  sub → sessionId  row.
  1. Invoke with both headers. It calls  InvokeAgentRuntime  with  X-Amzn-Bedrock-AgentCore-Runtime-User-Id  = the  sub  and  X-Amzn-Bedrock-AgentCore-Runtime-Session-Id  = the mapped session id, so the request sticks to the right microVM and scopes memory/data to the right identity.
  1. Verify on resume. If a client sends a session id, the backend checks it actually belongs to the authenticated  sub  in the store before using it, and never trusts a client-supplied session id blindly.
Attribute
Role
 sub  (partition key)
The authenticated user identity from the JWT
 sessionId 
The AgentCore runtime session bound to that user
 status  /  lastActiveAt 
Lifecycle bookkeeping (reap idle rows, cap sessions per user)
 createdAt 
Audit + rotation
Authenticated request path (sequence):


The  sub → sessionId  row is the multi-tenant boundary. AgentCore won't bind it for you.
💡 Why a persistent store: The microVM stops after ~15 min idle / 8 h max, but the  sessionId  stays valid and resumes with fresh compute, so the mapping must outlive the compute. Keeping it in the backend's own store (not in agent memory) means a returning user reconnects across stop/resume cycles, and a backend restart doesn't lose the binding.




3. Memory

Context-aware agents with control over what is remembered. Discipline to hold: memory stores intent & preferences, never business ground truth. Authoritative data comes from a tool/database call every turn.

Two types


Short-term
Long-term
What
Raw interaction events (the transcript)
Structured insights extracted from events (the lessons)
Written by
 CreateEvent  (actorId + sessionId)
Async extraction → consolidation (background)
Retrieved by
 ListEvents  /  GetEvent 
 RetrieveMemoryRecords  (semantic search)
Lifecycle
Expire by per-event TTL (up to 365d)
Persist & consolidate across sessions
Design for asynchronous extraction: Long-term extraction is event-triggered and asynchronous: it fires when an event is written, during the session, not at session end. The delay people see is async lag, not a session-close batch. To make a fact usable this turn, write it deterministically ( CreateEvent / IngestData ) and read it back, or carry it in session context.

Four strategies and when to use

Strategy
Extracts
Example use
USER_PREFERENCE
Stated preferences/settings
Default units, preferred contact window, saved filters
SUMMARIZATION
Session summaries
"Welcome back, last time you were comparing X and Y."
SEMANTIC
Facts/knowledge
Standing facts about the user or their domain
EPISODIC
Discrete past events
Prior actions, past requests, previous searches

Keys & namespaces: the survival lever

Short-term events key on  actorId + sessionId . Long-term records persist under a namespace per strategy, with variables  {actorId} / {sessionId} / {strategyId} . Granularity decides what survives:
Namespace shape
Scope
 /…/actor/{actorId}/session/{sessionId}/ 
Per-session, dies with the session
 /…/actor/{actorId}/ 
Per-actor across sessions, the personalization backbone
 /…/  (strategy only)
Across all actors
Namespace rules that matter: Keep  {sessionId}  out of a preferences namespace, or the user loses those preferences next visit. Always end namespaces with a trailing slash ( /actors/Alice/ ) to prevent prefix collisions. Records are not overwritten per session: long-term consolidates (merge/update), short-term expires by per-event TTL, and changing the TTL is not retroactive.

Who controls storage vs retrieval

Python
# STORAGE (managed): the STRATEGY extracts; steer it with an extraction-prompt override
memoryStrategies=[{"userPreferenceMemoryStrategy":{
"name":"UserCriteria",
"namespaces":["/users/{actorId}/preferences/"], # actor-level = survives
"configuration":{"extraction":{"appendToPrompt":
"Extract only durable user preferences and settings. Ignore small talk."}}}}]

# STORAGE (deterministic): guarantee an exact record
data.create_event(memoryId=mid, actorId=sub, sessionId=sid, payload=[...])

# RETRIEVAL (you decide): namespace scope + semantic query
data.retrieve_memory_records(memoryId=mid,
namespace=f"/users/{sub}/preferences/",
searchCriteria={"searchQuery": user_message, "topK": 5})

Security: four layers

  • Encryption at rest: a service key always applies; add a customer-managed KMS key (CMK) for sensitive data. Keep sensitive content out of event metadata, which is not covered by the CMK.
  • Namespace isolation: records scoped to a namespace keyed off the validated  actorId ; another user's records aren't addressable.
  • IAM on scopes: restrict operations with  bedrock-agentcore:namespace  /  namespacePath  conditions.
  • Runtime isolation: compounds it, so records are not addressable AND not reachable from another user's microVM.
Key takeaway: Storage = the strategy decides (managed, async; steer via override or guarantee via  CreateEvent ). Retrieval = you decide via namespace + semantic query, or let the Harness  memory  block auto-inject. Config lives on the Memory resource;  actorId  derives from the JWT  sub .




4. Identity: inbound, OBO, 3LO

Compatible with any OIDC IdP (Cognito, Okta, Entra ID, Auth0). Two halves: inbound (who may invoke the agent) and outbound (how the agent gets credentials to call downstream).
Refresh inbound tokens early and mind the ~60s freshness floor. AgentCore Runtime's inbound JWT authorizer rejects a token that has less than ~60 seconds of remaining lifetime, returning HTTP 401 with JSON-RPC code  -32001 , "Ineffectual token, will expire within the next minute", even when signature,  iss ,  aud , and  exp  all otherwise validate. There is no TTL-floor field on  CustomJWTAuthorizerConfiguration , so this is not configurable. On the Gateway MCP inbound path the same near-expiry guard can surface instead as a 403  insufficient_scope , so do not key retry logic solely on the message string. Best practice: treat a token with under ~120 seconds remaining as already expired and refresh it proactively rather than letting it coast toward  exp .

OBO in one paragraph

OBO converts a validated inbound user token into a new token minted for a downstream resource, carrying both the user (subject) and agent (actor). AgentCore brokers it ( GetWorkloadAccessTokenForJWT  →  GetResourceOauth2Token ); the IdP makes the final authorization call;  @requires_access_token  injects the token into the tool so the model never sees it. Two grant flavors: RFC 8693 (TOKEN_EXCHANGE, + M2M/IAM/NONE actor) or RFC 7523 (JWT_AUTHORIZATION_GRANT, which Microsoft Entra uses).
OBO sequence (the user has already signed in, so no consent screen appears):



3LO vs OBO: decide by one question

Does the user still need to approve? Yes → 3LO. No → OBO.

3-legged (authorization-code)
OBO
User present / consent screen?
Yes (first time)
No
Target
Third-party the user owns
Your own / trusted internal, tied to session
Starting material
No grant yet
Validated inbound token in hand
Produces
Stored refresh token (reused silently)
Freshly exchanged downstream token
Typical use
"Connect Salesforce/Google"
Internal APIs or a login-gated system, as the user
3-legged (authorization-code) sequence (the defining beat is step 3, the user approving on the provider's consent screen):


⚠️ Does 3LO become OBO? No. After first consent, 3LO keeps working via its stored refresh token (still 3LO). OBO is a separate token-exchange. 3LO establishes a grant; OBO propagates an identity. They can chain, but one never turns into the other. Add M2M (agent as itself) for the full taxonomy.




5. Gateway

A single secure entry point for all agentic traffic: agent→tools, agent→agent, agent→models. The only fully-managed service with both ingress AND egress auth.

Six capabilities

  • Security Guard: OAuth authz; only valid users/agents in.
  • Translation: MCP ↔ API/Lambda.
  • Composition: many APIs/functions/agents/models behind one endpoint.
  • Secure Credential Exchange: per-tool credential injection (key never touches the agent).
  • Semantic Tool Selection:  x_amz_bedrock_agentcore_search ; thousands of tools without prompt bloat.
  • Infrastructure Manager: serverless, built-in observability/audit.
Tool input types: OpenAPI, Smithy, Lambda. Fronts other agents/HTTP (incl. A2A) as passthrough targets. 1-click integrations: Salesforce, Slack, Jira, Asana, Zendesk.
MCP vs A2A, keep them straight: Gateway's native ingress protocol is MCP, which is how agents call it. A2A is a Runtime protocol an agent can natively speak (HTTP/MCP/A2A), and Gateway can optionally front an A2A agent as a passthrough target. Gateway supports A2A, but A2A is not inherent to Gateway: two agents can speak A2A directly on Runtime with no Gateway in between. Set the exposed protocol on the agent's runtime spec at  agentcore configure -p MCP , persisted in  agentcore.json .
💡 Least-privilege pattern: Split read and write tools across two Gateways (e.g. a read-only Gateway and an ops Gateway) so a compromised read path can't mutate state.




6. Policy, Guardrails & Temporal

A Policy Engine attaches to a Gateway and intercepts every tool call before execution. Cedar = deterministic authz; Guardrails-in-policy = ML detection; Temporal = session-history rules.
Plain text
Request → Cedar (who/what) → Guardrails (forbid input) → Temporal (session history) → Tool runs
deny-by-default · suppressOutput also filters the RESPONSE after the tool runs
Guardrails detect (probabilistic); Policy decides (deterministic).

Guardrails-in-policy

Three safeguards: ContentFilter (VIOLENCE/HATE/SEXUAL/MISCONDUCT/INSULTS), PromptAttack (JAILBREAK/PROMPT_INJECTION/PROMPT_LEAKAGE), SensitiveInformation (SSN, card, email, AWS keys, …). Two effects:  forbid…when guardrails  (block input) and  suppressOutput…when guardrails  (redact response).
Python
# Block prompt injection on the way in
forbid (principal, action == AgentCore::Action::"<Target>___POST:/invocations", resource)
when guardrails {
BedrockGuardrails::PromptAttack(["PROMPT_INJECTION"], [context.input.prompt])
["PROMPT_INJECTION"].confidenceScore.greaterThan(decimal("0.6"))
};
Defaults: ContentFilter 0.2, PromptAttack 0.4, SensitiveInfo 0.2. Scores discrete {0, .2, .4, .6, .8, 1}. IAM needs  bedrock:InvokeGuardrailChecks . Tune via LOG_ONLY → confusion matrix → ENFORCE.

Temporal policies (Dogwood)

Decisions based on the agent's history within a session, not just the current request. Written in Dogwood (a Cedar superset, so existing Cedar still works). Operators:  formerly within ,  since within ,  count ,  sum . History scoped to a policy session keyed by  x-amzn-bedrock-agentcore-policy-session-id  (you generate it; Gateway won't).
Plain text
# Permit a write ONLY if a matching confirmation happened in the last 5 min
permit (principal, action == AgentCore::Action::"my-target___commit_change", resource == ...gateway...)
when temporal {
formerly within 5m AgentCore::Action::"my-target___confirm_change"::response {
input.recordId: context.input.recordId, output.approved: true
}
};
⚠️ Caveats: Changing a temporal policy invalidates open sessions → next reuse fails 409 ConflictException (start a new session). Same account + Region only; needs  GetWorkloadAccessToken  on the gateway role. Quotas apply (confirm current values in the docs): on the order of 20 policies per engine, 3 operators per policy, and a 24-hour max window. Temporal ≠ time-based (wall-clock  context.system.now ).
💡 Region: Guardrails-in-policy and Temporal policies are available in a subset of Regions (e.g. us-east-1, us-east-2, us-west-2 Oregon, and others) but not all, notably not us-west-1 (N. California). Always check the live Region matrix before you design around them.
Pattern: infrastructure-enforced confirmation. A temporal policy can make a human-in-the-loop confirmation gate infrastructure-enforced: permit the write only if a matching confirmation action occurred in session history within a short window, so the write is impossible without a recent approval, not merely "the prompt asks nicely."




7. Browser

Secure, isolated, cloud browser for sites with no API.
  • When to use: integration of last resort. Only when there's no API and it's a system you don't control. For your own systems, wrap a Gateway target instead.
  • Isolation: containerized, ephemeral sessions that reset after use; auto-terminate on TTL (default 15 min, max 8 h). A hostile page has no agent environment to steal from.
  • Observability / audit: Live View (watch + human takeover for MFA/CAPTCHA), session recording (DOM, actions, console, network) to your S3, replayable in console; CloudTrail + CloudWatch.
  • Untrusted content: transcribe structured fields; never follow page prose as instructions.
  • Frameworks: Playwright, BrowserUse, Nova Act, Strands.
💡 Acting as the user: For a login-gated site, pair Browser with Identity: obtain a per-user token (OBO in production), drive the login as the user, and read the result, so the token is injected into the tool and never enters the model's context.




8. Code Interpreter

Isolated sandbox for the agent to execute code (Python/JS/TS).
  • Why the sandbox instead of the model for exact work: reproducibility and correctness. Exact computation through a probabilistic model isn't auditable or stable; the sandbox is deterministic and inspectable.
  • Security: isolated, ephemeral, zero AWS credentials in the sandbox: data in, numbers out. Even arbitrary generated code can't reach AWS resources.
  • Sessions: short-lived; spun up for the computation, torn down after (a Terminated entry is proof it ran).
  • Guaranteeing it runs: there is no hard  toolChoice  (see Harness); detect-and-retry is the mitigation, and export-to-Strands + forced  toolChoice  + structural gate is the hard version.
💡 Proof-of-execution pattern: Have the result card/record render directly from the sandbox's stdout (e.g. a JSON line). Its presence is evidence the sandbox executed, and a bypass can't fabricate it.




9. Top caveats cheat-sheet

The non-obvious things that separate a marketing answer from an architecture answer.
Area
The caveat
Harness
No deterministic tool-forcing in harness config; detect-and-retry is a safety net, not a guarantee. Export to Strands for a hard gate.
Runtime
Isolates the microVM but does NOT map users to sessions; your backend binds  sub → sessionId . That's the real multi-tenant risk.
Identity (inbound JWT)
~60s freshness floor: a token with under ~60s left is rejected 401  -32001  "Ineffectual token…", even if otherwise valid; on the Gateway path it can surface as a 403  insufficient_scope . Refresh when under ~120s remain; don't key retries on the message string.
Memory
Long-term extraction is async (during-session, not at end). Metadata isn't CMK-encrypted. Records consolidate, never per-session overwrite. TTL change isn't retroactive.
Identity (outbound)
A credential-store shortcut isn't true OBO. Production hardening = OBO token exchange (RFC 8693/7523) so the model never holds the token. Confirm the IdP's grant support early.
Gateway
MCP is native ingress; A2A is a Runtime protocol Gateway can optionally front, not inherent to Gateway.
Policy
Guardrails detect (probabilistic); Cedar/Temporal decide (deterministic). Can't mix Cedar + guardrails in one condition. Temporal changes invalidate sessions (409).
Browser
Integration of last resort; prefer an API/Gateway target whenever one exists.
Code Interpreter
Zero AWS creds in the sandbox; can't be forced from Harness config.



Conclusion

If there is one idea to carry out of this guide, it is the line that runs through every section: AgentCore gives you strong primitives, but production quality lives in how you compose them, and in the handful of boundaries where the default is not the safe choice. The microVM is isolated, yet binding the right user to the right session is yours to own. A credential vault keeps tokens out of the model, yet it only pays off if you reach for On-Behalf-Of exchange instead of a credential-store shortcut. A tool exists, yet "the model will call it" is a hope until you force it in a loop you control or make the result impossible to fake. The services are the easy part; the judgment about where to stop trusting a default is the work.
A practical way to use what you have read: treat the caveats cheat sheet as a design-review checklist. Before you call an AgentCore workload production-ready, walk each row and ask whether your architecture has actually handled it, the session-to-user binding, the token freshness floor, the namespace that decides what a user remembers, the Region gaps in Policy. Most AgentCore incidents trace back to one of those rows being assumed rather than addressed.
From here, the natural next steps are to prototype the one capability your workload leans on hardest, instrument it with Observability early so you can see the session and token boundaries in real traces, and re-validate the moving targets, which is to say Region availability, quotas, and API shapes, against the current AWS documentation before you commit to a design. The architecture reasoning in this guide is durable; the specific numbers are not, so pair the two.
If this was useful, take it into your next design review and see how many of the nine caveats your current plan already answers. That number is a fair first estimate of how close you are to production.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article