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.
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"]
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-trackeris a classic API Gateway proxy Lambda. It routes onevent["resource"]andpathParameters. -
refund-processoris 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"
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.
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)
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)
-
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:
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})
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
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]
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
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 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
amountrequired 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)