DEV Community

Cover image for I built a customer support agent on Amazon Bedrock AgentCore β€” and it refused to say my name

I built a customer support agent on Amazon Bedrock AgentCore β€” and it refused to say my name

I spent ten years in asset management, most of it translating what portfolio managers and quants built into something clients could actually follow. Now I build agents. It turns out the job is weirdly similar: an agent is mostly a translation layer between a person asking a vague question and a set of systems that only understand precise ones.

This is the second project of the AWS AI & ML Scholars β€” Future Agent Engineer track: a customer support agent for a fictional e-commerce store, running on Amazon Bedrock AgentCore. Here's how it's put together, the three things that tripped me up, and one bug that passed every test.

πŸ‘‰ Repo: github.com/Mialy333/agentcore-customer-support-agent


What the agent does

One conversational entrypoint, six capabilities:

Ask What answers it
"Where's my order ORD-001?" AgentCore Gateway (MCP) β†’ API Gateway β†’ Lambda
"Refund my Kindle" AgentCore Gateway (MCP) β†’ Lambda
"What does Platinum tier get me?" Bedrock Knowledge Base (RAG)
"Do you remember me?" AgentCore Memory via a Strands hook
"What's my discount with 4,250 points?" Code Interpreter (sandboxed Python)
"What's on this web page?" Browser Tool

It runs on AgentCore Runtime (serverless, direct code deploy, Python 3.13), uses the Strands Agents SDK, and reasons with Amazon Nova 2 Lite.

Architecture

The whole agent fits in a single main.py. The entrypoint is short because each capability is a tool or a hook:

@app.entrypoint
async def invoke(payload, context=None):
    user_input = payload.get("prompt", "")
    actor_id = payload.get("customer_id", "anonymous")
    session_id = payload.get("session_id") or str(uuid.uuid4())

    memory_hook = MemoryHook(actor_id, session_id, memory_client, MEMORY_ID)
    browser = AgentCoreBrowser(region=REGION)
    tools = [search_knowledge_base, calculate_loyalty_discount, browser.browser]

    gateway_client = MCPClient(lambda: streamable_http_client(GATEWAY_URL))
    with gateway_client:
        all_tools = tools + gateway_client.list_tools_sync()
        agent = Agent(model=model, tools=all_tools,
                      hooks=[memory_hook], system_prompt=SYSTEM_PROMPT)
        return agent(user_input).message["content"][0]["text"]
Enter fullscreen mode Exit fullscreen mode

Nine tools reach the model: three local ones, plus six discovered at runtime from the Gateway over MCP. The agent doesn't know (or care) that get_order lives behind API Gateway while initiate_refund is a bare Lambda β€” it just sees an MCP tool list.


1. Gateway: two very different backends, one MCP endpoint

This was the part I found most "production-shaped". The two Lambdas have nothing in common:

  • order-tracker is a classic API Gateway proxy Lambda. It routes on event["resource"] and pathParameters.
  • refund-processor is called directly by the Gateway. It never sees an HTTP request; it reads the tool name from the Lambda client context:
raw_tool = context.client_context.custom.get("bedrockAgentCoreToolName", "")
tool = raw_tool.split("___", 1)[-1]   # "refund-processor___initiate_refund" β†’ "initiate_refund"
Enter fullscreen mode Exit fullscreen mode

AgentCore Gateway wraps both as targets of one MCP server. For the REST API, each operation ID (get_order, get_customer, get_customer_orders) becomes a tool name. For the Lambda, you paste a JSON tool schema.

Gotcha #1 β€” target names reject underscores. I named my targets order_tracker and refund_processor, matching the Lambda files. The Gateway was created fine… and then both targets failed: the name must match ([0-9a-zA-Z][-]?){1,100}. Hyphens only. order-tracker and refund-processor went through.

Gateway validation error

Before writing any agent code, npx @modelcontextprotocol/inspector against the Gateway URL is the fastest way to confirm all six tools are actually exposed.


2. RAG: the boring part that just works

The knowledge base is a single product_catalog.txt (products, return windows, refund timelines, loyalty tiers) uploaded to S3 and synced into a Managed Knowledge Base. The tool is a thin wrapper around the Retrieve API:

resp = _bedrock_runtime.retrieve(
    knowledgeBaseId=KB_ID,
    retrievalQuery={"text": query},
)
chunks = [r["content"]["text"] for r in resp.get("retrievalResults", [])
          if r.get("content", {}).get("text")]
return "\n---\n".join(chunks)
Enter fullscreen mode Exit fullscreen mode

Asked about the Platinum tier, the agent returned exactly what the catalog says β€” free same-day shipping, 15% discount, priority support β€” instead of whatever the model half-remembers about loyalty programs in general. That's the whole point of RAG: answers you can trace back to a document.


3. Memory: a hook, not a prompt rebuild

AgentCore Memory is configured with two built-in strategies:

  • Semantic β†’ cs_agent/{actorId}/facts
  • User preference β†’ cs_agent/{actorId}/preferences

The agent saves raw conversation events; AgentCore extracts durable facts and preferences from them in the background.

On the agent side, I used a Strands HookProvider with two callbacks:

def register_hooks(self, registry):
    registry.add_callback(MessageAddedEvent, self.retrieve_customer_context)
    registry.add_callback(AfterInvocationEvent, self.save_support_interaction)
Enter fullscreen mode Exit fullscreen mode
  • Before the model sees a user message, the hook queries every namespace and prepends a Customer Context: block to that message.
  • After the agent answers, it saves the (user, assistant) pair with create_event.

The design choice I like: the agent's reasoning loop never knows memory exists. It just receives a slightly richer message. Retrieval and saving each live in their own method, and tool-result messages are skipped so memory is only queried on real user turns.

Gotcha #2 β€” the agent refused to say my name

The test: in session A, "Hi, I am Jane. I prefer concise responses." Wait 30 seconds for extraction. In session B, "Do you remember my name and communication preference?"

First run: it recalled "prefers concise responses"… and then declined to tell me my name, explaining it didn't have access to personal identification details for security reasons.

Nothing in my code blocked it. The memory record was right there in the context. The model itself decided a name looked like sensitive data and got cautious β€” about a name the customer had volunteered minutes earlier.

The fix was a few sentences in the system prompt: the Customer Context block contains facts the customer previously and voluntarily shared, and stating them back is expected behavior. After that:

Memory recall across sessions

It remembered Jane, the preference, and her two recent orders. Lesson: guardrails baked into a foundation model can misfire in directions you never asked for, and sometimes prompt engineering means removing caution rather than adding it.


4. Code Interpreter: don't let an LLM do the arithmetic

Loyalty math has just enough rules to go wrong: points redeem in 500-point blocks ($5 each), capped at 50% of the order, then a tier discount (Gold 10%, Platinum 15%) applies, then you earn points on what you paid.

Instead of trusting the model, calculate_loyalty_discount generates a small Python program and runs it in the AgentCore Code Interpreter sandbox:

with code_session(REGION) as session:
    response = session.invoke("executeCode",
        {"code": code, "language": "python", "clearContext": True})
Enter fullscreen mode Exit fullscreen mode

Gold member, 4,250 points, $150 order:

  • 4,000 points redeemable β†’ $40
  • $110 Γ— 10% Gold discount β†’ $11
  • Final total $99.00, earns 99 points β†’ 349 points left

Loyalty discount

If the sandbox is unavailable, the tool falls back to a tier-only calculation and says so in its output, rather than failing silently or letting the model guess.


5. Browser: one line

browser = AgentCoreBrowser(region=REGION)
tools = [..., browser.browser]
Enter fullscreen mode Exit fullscreen mode

AgentCore runs a managed headless browser; the agent got the live page title from udacity.com. The only real work was IAM.


Deploying (and Gotcha #3)

The course intentionally uses the Python Bedrock AgentCore Starter Toolkit:

uv run agentcore configure --entrypoint main.py --name customer_support_agent \
  --deployment-type direct_code_deploy --runtime PYTHON_3_13 --disable-memory
uv run agentcore deploy
uv run setup_permissions.py
Enter fullscreen mode Exit fullscreen mode

setup_permissions.py reads the resource IDs from main.py as text (with ast, never importing the agent), checks the account and role match the deployment, then attaches one inline policy scoped to this Knowledge Base, this Memory and the managed browser. Least privilege, rerunnable.

Gotcha #3 β€” region drift. One configure run silently picked up us-west-2 while every resource lived in us-east-1. Read the configuration summary before you deploy.

Side note: every invoke now prints a notice that the Starter Toolkit is no longer supported in favor of the npm @aws/agentcore CLI. Don't install both β€” they ship the same agentcore binary.


The bug my tests didn't catch

All six scenarios passed. Then I re-read the refund output more carefully:

Refund processing

Refund ID βœ…, APPROVED βœ…, "3–5 business days" βœ…β€¦ and Refund Amount: $0. Plus a confident "you don't need to return anything, this was a digital product" β€” about a physical Kindle.

What happened: amount is optional in the refund tool's schema, the Lambda defaults it to 0, and the model never called get_order to look up the $139.99 total first. Then it filled the gap with a plausible story.

The test checked for the shape of the right answer, not for the answer being right. Fixes I'd ship in v2:

  • make amount required in the schema, or better, have the Lambda look the amount up server-side so the model can't get it wrong;
  • instruct the agent to read the order before acting on it;
  • assert on values, not keywords, in the tests.

This is the kind of thing I'd rather find in a sandbox than in a customer's bank account.


If this went to production

  • A real authorizer on the Gateway. The project uses the NONE inbound authorizer to stay focused on tool integration. That's fine for a sandbox and unacceptable for anything else: JWT/OAuth.
  • Retries and timeouts on the MCP connection.
  • Tighter IAM than the auto-created runtime execution role.
  • Cost monitoring. The vector store behind the Knowledge Base kept billing the whole time I was building, whether I was testing or not. Tear everything down when you're done (agentcore destroy, then Gateway, Memory, KB, vector store, S3, API Gateway, Lambdas).

Takeaways

The hardest part of AgentCore isn't the Python. It's getting Gateway, Memory, the Knowledge Base, the Code Interpreter and the Browser to agree with each other β€” same region, same protocol, same IAM role.

And the most interesting failures weren't infrastructure failures. They were the model being too careful with a name, and not careful enough with a refund amount. Which is, honestly, the same lesson I learned explaining portfolios to clients: the risk is rarely in the system you can see; it's in the gap between what someone asked and what got executed.

The full code, architecture diagrams, all six test screenshots and a 43-step setup walkthrough are in the repo:
πŸ‘‰ github.com/Mialy333/agentcore-customer-support-agent

I'm Mialy, building at the intersection of AI agents and digital assets as @ellebuild. If you've hit other AgentCore gotchas, I'd love to hear them in the comments.

Top comments (0)