Amazon Bedrock Agents (Classic) in Production: Safe Tools, Memory, Guardrails and the Move to AgentCore

Amazon Bedrock Agents (Classic) in Production: Safe Tools, Memory, Guardrails and the Move to AgentCore
28 views

Status update, October 2026: AWS has renamed Amazon Bedrock Agents to Amazon Bedrock Agents Classic and put it in maintenance mode. It is no longer open to new customers as of July 30, 2026; existing customers can keep using it, and AWS recommends migrating workloads to Amazon Bedrock AgentCore. Bedrock models, Knowledge Bases and Guardrails are not affected. This guide is for teams running Agents Classic today: how to keep those agents safe while they run, and how each piece maps to AgentCore when you move.

Most agent incidents don’t need a clever attack. A user writes “cancel all my orders” and the agent does it without asking. A tool Lambda accepts any order ID and returns the full database row, card details included. Or one customer’s agent session reads another customer’s data because the tenant came from the conversation instead of from your application. Each of these has a specific control in Agents Classic, and each control has a counterpart in AgentCore.

⚡ TL;DR
  • One narrow function per action-group operation. Validate every parameter in the Lambda and return REPROMPT for bad input; return only whitelisted fields.
  • Take tenant and user identity from sessionAttributes that your application sets on InvokeAgent, never from the model’s parameters.
  • Set requireConfirmation: ENABLED on functions with side effects; the agent then returns control so the user can CONFIRM or DENY.
  • Associate a guardrail with the agent (guardrailConfiguration on CreateAgent/UpdateAgent). The prompt attack filter applies to input only.
  • Memory (SESSION_SUMMARY, 1 to 365 days) is keyed by the memoryId you pass, so always pass a per-user value.
  • Plan the move: action groups become AgentCore gateway tools, return-of-control becomes inline function tools, memory becomes AgentCore memory.

Who this is for: teams with Bedrock Agents Classic in production, or inheriting one. Code is Python with boto3. The Lambda ran against sample events in the documented format, and API requests were validated against the boto3 service models.

How an Agents Classic request flows

Your application calls InvokeAgent with the user’s text, a sessionId and optional session state. The agent’s orchestration loop asks the model what to do; when it decides to call an action-group function, Bedrock invokes your Lambda with an event that includes the function name, parameters and the session attributes. The Lambda’s response goes back into the loop, and the final answer streams back as chunks. Three places in that loop are where the controls belong: the call (what your application puts in session state), the tool (what the Lambda accepts and returns), and the agent configuration (confirmation, guardrail, memory).

Figure 1. Controls in an Agents Classic deployment and where they move in AgentCore. Your application owns identity, the Lambda owns validation, and the agent configuration owns confirmation, guardrails and memory. Diagram by CheatCoders.
Figure 1. Controls in an Agents Classic deployment and where they move in AgentCore. Your application owns identity, the Lambda owns validation, and the agent configuration owns confirmation, guardrails and memory. Diagram by CheatCoders.

Step 1: Make every tool Lambda narrow and suspicious

An action group is a typed interface, not access to your AWS account. With a function-details schema, the Lambda receives function, parameters (each with name, type and value) and sessionAttributes. It must return messageVersion 1.0 and a functionResponse whose body uses the TEXT content type. Set responseState to REPROMPT when the input is invalid, so the model gets a chance to fix it, or FAILURE when a dependency failed.

python
"""Action group Lambda (function-details schema): one narrow capability, tenant from the caller."""
import json
import re

import boto3

table = boto3.resource("dynamodb").Table("orders")
ORDER_ID = re.compile(r"^[A-Z0-9-]{8,32}$")
SAFE_FIELDS = ("orderId", "status", "totalCents", "updatedAt")


def respond(event, body: dict, state: str | None = None) -> dict:
    fr = {"responseBody": {"TEXT": {"body": json.dumps(body)}}}
    if state:
        fr["responseState"] = state                 # REPROMPT: bad input; FAILURE: dependency failed
    return {"messageVersion": "1.0",
            "response": {"actionGroup": event["actionGroup"], "function": event["function"],
                         "functionResponse": fr}}


def handler(event, context=None):
    if event.get("function") != "get_order":
        return respond(event, {"error": "unsupported_function"}, "REPROMPT")
    params = {p["name"]: p["value"] for p in event.get("parameters", [])}
    order_id = params.get("orderId", "")
    if not ORDER_ID.match(order_id):
        return respond(event, {"error": "orderId must be 8-32 characters A-Z, 0-9 or -"}, "REPROMPT")

    # Set by YOUR application in InvokeAgent sessionState, never by the model or the end user.
    tenant = event.get("sessionAttributes", {}).get("tenantId")
    if not tenant:
        return respond(event, {"error": "missing tenant context"}, "FAILURE")

    item = table.get_item(Key={"pk": f"TENANT#{tenant}", "sk": f"ORDER#{order_id}"}).get("Item")
    if not item:
        return respond(event, {"orderId": order_id, "status": "NOT_FOUND"})
    return respond(event, {k: item[k] for k in SAFE_FIELDS if k in item})   # never raw items

Three rules in that handler matter more than the rest. The function name and parameters are checked against what you expect, because the model chooses them. The tenant comes from session attributes your application set, not from a parameter the model filled in. And the response is built from a whitelist, so a field like a card number never reaches the model’s context. Give the Lambda’s IAM role only the actions it needs on the specific table; an IAM condition with a wildcard tenant (for example dynamodb:LeadingKeys set to TENANT#*) restricts nothing.

Step 2: Require confirmation for actions with side effects

AWS’s own guidance on prompt injection is direct: if a tool or knowledge base result contains instructions, the agent may follow them. For functions that change something, enable user confirmation. The agent then doesn’t call the Lambda; it returns control to your application with the function and parameters, and waits for a CONFIRM or DENY.

python
"""Agent configuration: confirmation on risky functions, guardrail, memory, idle timeout."""
import boto3

agent = boto3.client("bedrock-agent", region_name="us-east-1")
AGENT_ID, ROLE = "AGENT12345", "arn:aws:iam::111122223333:role/BedrockAgentRole"
LAMBDA = "arn:aws:lambda:us-east-1:111122223333:function:orders-tools"


def configure_tools():
    agent.create_agent_action_group(
        agentId=AGENT_ID, agentVersion="DRAFT", actionGroupName="orders",
        actionGroupExecutor={"lambda": LAMBDA},
        functionSchema={"functions": [
            {"name": "get_order", "description": "Look up one order by ID for the current customer.",
             "parameters": {"orderId": {"type": "string", "required": True,
                                        "description": "Order ID, 8-32 chars"}}},
            {"name": "cancel_order", "description": "Cancel one unshipped order for the current customer.",
             "parameters": {"orderId": {"type": "string", "required": True, "description": "Order ID"}},
             "requireConfirmation": "ENABLED"},          # user must CONFIRM before the Lambda runs
        ]})


def configure_agent(guardrail_id: str, guardrail_version: str):
    agent.update_agent(
        agentId=AGENT_ID, agentName="orders-assistant", agentResourceRoleArn=ROLE,
        foundationModel="anthropic.claude-3-5-sonnet-20241022-v2:0",
        instruction="You help customers check and cancel their own orders. Use only the provided tools.",
        idleSessionTTLInSeconds=900,
        guardrailConfiguration={"guardrailIdentifier": guardrail_id, "guardrailVersion": guardrail_version},
        memoryConfiguration={"enabledMemoryTypes": ["SESSION_SUMMARY"], "storageDays": 30})
    agent.prepare_agent(agentId=AGENT_ID)             # then create or update an alias to a new version


def create_guardrail():
    bedrock = boto3.client("bedrock", region_name="us-east-1")
    return bedrock.create_guardrail(
        name="orders-assistant",
        contentPolicyConfig={"filtersConfig": [
            {"type": "PROMPT_ATTACK", "inputStrength": "HIGH", "outputStrength": "NONE"},
            {"type": "MISCONDUCT", "inputStrength": "MEDIUM", "outputStrength": "MEDIUM"}]},
        sensitiveInformationPolicyConfig={"piiEntitiesConfig": [
            {"type": "CREDIT_DEBIT_CARD_NUMBER", "action": "BLOCK"},
            {"type": "EMAIL", "action": "ANONYMIZE"}]},
        blockedInputMessaging="Sorry, I can't help with that request.",
        blockedOutputsMessaging="Sorry, I can't share that.")

Your application handles the returnControl event, shows the user exactly what will happen, and sends the decision back in the next InvokeAgent call as returnControlInvocationResults with the same invocationId:

python
"""Calling the agent: tenant in sessionAttributes, per-user memoryId, and the confirmation round trip."""
import boto3

runtime = boto3.client("bedrock-agent-runtime", region_name="us-east-1")


def ask(user, session_id, text, confirm=None, session_state=None):
    state = session_state or {"sessionAttributes": {"tenantId": user["tenant"]},
                              "promptSessionAttributes": {"locale": user["locale"]}}
    resp = runtime.invoke_agent(agentId="AGENT12345", agentAliasId="PRODALIAS", sessionId=session_id,
                                memoryId=user["id"], inputText=text, sessionState=state)
    return read_stream(resp["completion"], confirm, state)


def read_stream(events, confirm, state):
    text = []
    for ev in events:
        if "chunk" in ev:
            text.append(ev["chunk"]["bytes"].decode())
        elif "returnControl" in ev:                     # agent wants confirmation before a risky call
            rc = ev["returnControl"]
            results = []
            for item in rc["invocationInputs"]:
                fi = item["functionInvocationInput"]
                ok = confirm(fi["function"], {p["name"]: p["value"] for p in fi["parameters"]})
                results.append({"functionResult": {"actionGroup": fi["actionGroup"], "function": fi["function"],
                                                   "confirmationState": "CONFIRM" if ok else "DENY"}})
            return {"pending": {"invocationId": rc["invocationId"], "returnControlInvocationResults": results,
                                **state}}
    return {"text": "".join(text)}

Note the memoryId. Memory stores session summaries against the ID you pass and loads them into the next session with the same ID, so a shared or constant value would mix users’ histories. Summaries are written after a session ends (explicitly with endSession or when the idle timeout elapses) and kept for the number of days you configure, from 1 to 365.

Step 3: Test the controls, not just the happy path

python
from unittest.mock import MagicMock
from botocore.stub import Stubber
import handler, configure, client

handler.table = MagicMock()
handler.table.get_item.return_value = {"Item": {"pk": "TENANT#acme", "sk": "ORDER#AB12CD34",
    "orderId": "AB12CD34", "status": "SHIPPED", "totalCents": 4900, "cardLast4": "4242"}}
ev = {"messageVersion": "1.0", "actionGroup": "orders", "function": "get_order",
      "parameters": [{"name": "orderId", "type": "string", "value": "AB12CD34"}],
      "sessionAttributes": {"tenantId": "acme"}}
print(handler.handler(ev)["response"]["functionResponse"])
print(handler.handler({**ev, "parameters": [{"name": "orderId", "value": "x; DROP"}]})["response"]["functionResponse"])
print(handler.handler({**ev, "sessionAttributes": {}})["response"]["functionResponse"]["responseState"])

s = Stubber(configure.agent)
s.add_response("create_agent_action_group", {"agentActionGroup": {"agentId": "AGENT12345", "agentVersion": "DRAFT",
    "actionGroupId": "AG1", "actionGroupName": "orders", "createdAt": "2026-10-10T00:00:00Z",
    "updatedAt": "2026-10-10T00:00:00Z", "actionGroupState": "ENABLED"}})
with s:
    configure.configure_tools(); print("action group request valid")

events = [{"returnControl": {"invocationId": "inv-1", "invocationInputs": [{"functionInvocationInput": {
    "actionGroup": "orders", "function": "cancel_order", "actionInvocationType": "USER_CONFIRMATION",
    "parameters": [{"name": "orderId", "type": "string", "value": "AB12CD34"}]}}]}}]
def user_says_no(fn, args):
    return False
out = client.read_stream(events, user_says_no, {"sessionAttributes": {"tenantId": "acme"}})
print(out)
text
{'responseBody': {'TEXT': {'body': '{"orderId": "AB12CD34", "status": "SHIPPED", "totalCents": 4900}'}}}
{'responseBody': {'TEXT': {'body': '{"error": "orderId must be 8-32 characters A-Z, 0-9 or -"}'}}, 'responseState': 'REPROMPT'}
FAILURE
action group request valid
{'pending': {'invocationId': 'inv-1', 'returnControlInvocationResults': [{'functionResult': {'actionGroup': 'orders', 'function': 'cancel_order', 'confirmationState': 'DENY'}}], 'sessionAttributes': {'tenantId': 'acme'}}}

The lookup returned only whitelisted fields (no cardLast4), an injected order ID was sent back for reprompting, a missing tenant failed closed, the action-group request matched the API model, and a user’s refusal produced a DENY result ready to send back. Add cases like these to CI for every action group.

Step 4: Guardrails and limits

  • Guardrail: associate it with the agent through guardrailConfiguration (identifier and version) on CreateAgent or UpdateAgent, then prepare the agent and point an alias at a new version. The prompt attack filter is meant for user input (AWS’s examples set its output strength to NONE). It is one layer: it does not replace Step 2 confirmation or the validation in your tools. Sensitive information filters can block or anonymize PII types such as card numbers and emails.
  • Idle timeout: idleSessionTTLInSeconds accepts 60 to 5,400 seconds in the current API. Shorter sessions mean less stale context.
  • Tool timeouts: keep Lambda timeouts close to what the lookup really needs, so a slow dependency fails fast instead of holding the conversation.
  • Tracing: enable trace on InvokeAgent in non-production and log the session ID, tenant, function, status and whether the guardrail intervened.

Step 5: Map the design to AgentCore

AWS’s migration guide recommends the AgentCore managed harness (a configuration-based agent where you declare model, tools and instructions) unless you need to own the orchestration loop, in which case you deploy a code-defined agent on AgentCore. The AgentCore CLI can import an existing Agents Classic configuration as a starting point. The guide maps the pieces like this:

Agents ClassicAgentCore equivalent
Action groups (OpenAPI or function schema + Lambda)Tools exposed through AgentCore gateway as MCP tools wrapping REST APIs, Lambda functions or code-level tools
Return of control and user confirmationInline function tools: the harness pauses and returns the tool call to your client
Session and memory configurationAgentCore memory, short and long term, with configurable strategies
Guardrail on the agentGuardrail configured in Bedrock, enforced through AgentCore gateway
Knowledge bases on the agentGateway-fronted knowledge base integration, or a retrieval tool
Stage-specific prompt overridesNot directly replicated in the harness; system prompt plus your own logic, or a code-defined agent

Because the tool Lambdas above validate their own input, take identity from the caller and whitelist their output, they can sit behind an AgentCore gateway with little change. The parts to redesign are the confirmation round trip in your client and how tenant identity reaches the tools, since sessionAttributes is an Agents Classic feature. Check the AgentCore documentation for the current details before you start; it’s a newer service and changing quickly.

Worked example: “cancel my last order”

  1. The web app calls InvokeAgent with tenantId in session attributes and the user’s ID as memoryId.
  2. The agent calls get_order; the Lambda validates the ID, reads the tenant’s row and returns status and total only.
  3. The agent decides to call cancel_order. Because confirmation is enabled, the app receives returnControl and shows “Cancel order AB12CD34 (9.00)?”.
  4. The user taps Cancel. The app sends DENY; the Lambda is never invoked, and the agent replies that nothing was changed.
  5. When the team later moves to AgentCore, the same two Lambdas become gateway tools and cancel_order becomes an inline function tool with the same client-side confirmation.

Troubleshooting

SymptomLikely causeFix
DependencyFailedException in the sessionLambda returned FAILURE or an invalid response shapeMatch the documented response format; use REPROMPT for bad input
Agent ignores a new action group or guardrailAgent not prepared, or alias still points to an old versionRun PrepareAgent, create a version, update the alias
Users see each other’s historySame memoryId for different usersPass a stable per-user ID
Confirmation never appearsrequireConfirmation not set on that function, or the client ignores returnControlEnable it per function; handle the event in your stream reader

Production checklist

  • ☐ One function per operation; parameters validated; responses whitelisted
  • ☐ Tenant and user identity from application-set session attributes
  • ☐ Confirmation on every function with side effects; client handles returnControl
  • ☐ Guardrail associated with the agent and tested with real attack prompts
  • ☐ Per-user memoryId; memory retention set deliberately
  • ☐ Least-privilege Lambda roles; short timeouts; traces in non-production
  • ☐ CI tests for injected parameters, missing tenant and denied confirmations
  • ☐ Migration plan to AgentCore with an owner and a date

FAQ

Can I still create new Bedrock Agents?

Agents Classic isn’t open to new customers since July 30, 2026. Existing customers can continue to use it; for new agents, AWS points to AgentCore.

Do I have to migrate right away?

AWS describes Agents Classic as being in maintenance mode and recommends migrating. Plan it, but harden what’s running in the meantime; the controls here carry over.

Is a guardrail enough to stop prompt injection?

No. It’s one layer. Confirmation on risky actions, validation in tools and identity from your application are what limit the damage when an injection gets through.

Where should conversation history live?

Let the agent’s session and memory handle it. Don’t paste long transcripts into inputText on every call.

Official documentation

Related on CheatCoders: Strict tool contracts with the Converse API · Idempotent tool calls with DynamoDB

Last updated on October 10, 2026

Watch: 100 Days of AI on YouTube

Short videos from CheatCoders, one AI engineering topic per day.

Open the playlist on YouTube · CheatCoders on YouTube

Deep-dive PDF

Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.

Already subscribed? or open the subscribe page.


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

2 Comments

  1. Honestly, when I first hired an SEO consultant I expected some secret formula, but it turned out to be mostly research and analysis. They look at your site structure, keywords, backlinks, loading speed, and basically translate all that technical stuff into a plan you can actually follow. Mine also spent a lot of time just reading through analytics reports, which sounds boring but apparently matters a ton.

    What surprised me most is how much of the job is just communication, explaining to clients why rankings don’t improve overnight and managing expectations. A lot of consultants also coordinate with whoever writes your content or builds your site, since SEO touches basically everything. Anyone here actually worked with one long term, was it worth the monthly cost?

    Been doing my own SEO for years but finally brought in outside help when traffic plateaued. What surprised me was how much time he spent asking about my business goals before even touching keywords. Turns out half my problem wasn’t technical, it was just targeting the wrong audience the whole time.

    My advice after going through this is don’t expect overnight miracles, good consultants will actually tell you that upfront. The ones who promise page one in a month are usually the ones you should avoid. Anyone here had a similar experience with consultants overpromising and underdelivering?
    SEO consultant advice [url=https://yourhomeyourwayltd.co.uk/what-an-seo-consultant-actually-does-advice/#SEO-consultant-advice]https://yourhomeyourwayltd.co.uk/what-an-seo-consultant-actually-does-advice/[/url] .

Leave a comment

No account needed. Name and email are optional.