A coordinator and three specialists for a fictional DTC outdoor-gear brand — provisioned end to end with the new @aws/agentcore-cli, not a single hand-rolled boto3 script. The new teaching point: AgentCore Memory branching, so three specialists can work a ticket at once without stepping on each other's context.
Beacon is the support agent for Northwind Outfitters, a fictional DTC outdoor-gear brand. A day of tickets looks like: "where's my order," "I want to return these boots, what's my refund," "my tracking hasn't moved in five days, is that normal," and, occasionally, "I was charged for an order I never authorized — get me a person."
Those four shapes of work don't share a tool set or even a shape of correctness. An order-status lookup is a single read. A refund is arithmetic against a policy — restocking fee thresholds, a 30-day window, final-sale exceptions — and arithmetic is exactly what you don't want an LLM doing silently in its head. A tracking-stall complaint needs a live check against the carrier's own service-alerts page, because carriers don't publish a clean "are we delayed" API for a workshop to call. And the fraud complaint needs a human, now, with enough context that they don't have to re-read the whole conversation.
One coordinator agent could try to do all four itself. It would work, in the same way a 400-line system prompt with twelve tools works — right up until you need to reason about which of those tools actually ran, or until two of those ticket shapes need to happen at the same time without their working context bleeding into each other.
This is the third workshop in the series, after Atlas Deal Desk and StockPilot. Both of those used the Python bedrock-agentcore-starter-toolkit and a folder of boto3 provisioning scripts — bin/setup.py creating an S3 bucket, an IAM role, a Memory store, by hand, one API call at a time.
AWS has since shipped @aws/agentcore-cli, and it changes the provisioning story completely. agentcore create scaffolds a declarative agentcore.json — a flat resource model where runtimes, memories, gateways, and credentials are independent top-level arrays — plus an auto-managed CDK project underneath it. agentcore add memory --strategies SEMANTIC,USER_PREFERENCE,SUMMARIZATION,EPISODIC writes a memory resource into that JSON. agentcore add gateway-target --type lambda-function-arn wires a Lambda into an MCP gateway. No boto3, no hand-written CloudFormation — agentcore deploy synthesizes the CDK stack and ships it.
Provision via agentcore add; write code only for what the CLI doesn't model. That turned out to be exactly one thing here: the mock order database and the Lambda that reads it. Everything AgentCore-native — Memory, Gateway, the two gateway targets, the inbound/outbound auth — is declared in agentcore.json, not hand-rolled.
Concretely, this repo's bin/ folder is three scripts, not the six or seven you'd expect from the older toolkit: setup_backend.py (DynamoDB table + seed data), deploy_order_lambda.py (zip and ship the Lambda — the CLI only tracks its ARN, it doesn't own the Lambda's code), and teardown.py. Memory, Gateway, and both gateway targets never appear in bin/ at all.
Atlas Deal Desk used AgentCore Memory but not branching — its four sub-agents ran, returned a compact summary, and were done. Beacon adds the piece Atlas didn't need: three specialists that can be invoked more than once across a session, concurrently with each other, and whose own turn-by-turn history has to stay theirs.
AgentCore Memory's branching primitive is exactly a git branch for a conversation: a session has a "main" line of events, and create_event(..., branch={"name": ..., "rootEventId": ...}) forks a new line off any point in it. Give each specialist its own branch name, fork it off the coordinator's current turn, and three specialists can work the same ticket at once without any of them seeing the others' scratch work — while still being able to read their own prior turns if the coordinator calls them again later in the same session.
def open_branch(client, memory_id, actor_id, session_id, branch_name):
# Fork off the current tip of "main" — unused after branch creation,
# record() only sends rootEventId on a branch's very first event.
root_event_id = _tip_of_main(client, memory_id, actor_id, session_id)
return SpecialistBranch(client, memory_id, actor_id, session_id, branch_name, root_event_id)
# in the coordinator's dispatch tool:
results = await asyncio.gather(*(_run_specialist(d, request) for d in chosen_domains))
The eval harness's one honestly-different task (a "my order is late AND I want a refund" ticket that needs both Shipping and Returns) doesn't get a better answer from this — both specialists are perfectly capable of running one after another. What changes is wall-clock time, because asyncio.gather runs them at once instead of in sequence, and auditability, because each specialist's reasoning lands on a branch you can pull up in isolation afterward instead of interleaved into one conversation.
This repo's own eval suite says so directly, in the module docstring: multi-agent mode isn't "smarter" on any single task — it's faster on the cross-domain one and more auditable everywhere memory is deployed. That's the claim the --compare flag exists to check, not oversell.
BeaconMemory carries four strategies, declared once in agentcore.json via agentcore add memory --strategies SEMANTIC,USER_PREFERENCE,SUMMARIZATION,EPISODIC. The coordinator gets all four for free: AgentCoreMemorySessionManager's retrieval_config retrieves matching records from every configured namespace and injects them into the model's context automatically, every turn.
The three specialists deliberately don't use that session manager — they're on isolated branches, not the main session, so the automatic injection doesn't reach them. Rather than let that mean "specialists forget who the customer is," a small SpecialistMemoryHooks hook queries the SEMANTIC and USER_PREFERENCE namespaces directly on a specialist's first turn:
class SpecialistMemoryHooks(HookProvider):
"""Give a specialist the same semantic-facts + preference recall the
coordinator gets for free, since specialists skip the session manager
entirely (they use isolated memory branches instead — see branch_hook.py)."""
def _inject_context(self, event: BeforeInvocationEvent) -> None:
context_items = recall(self.client, self.memory_id, self.actor_id, query_text)
if context_items:
last_message["content"].insert(0, {"text": f"<customer_context>{...}</customer_context>"})
Escalation handoff works the same way, from the other direction: tools/escalation.py is one line — flag agent.state["escalation"] — and a separate hook, EscalationSummaryHooks, notices that flag after the turn ends and composes the actual handoff note, pulling from whatever the SUMMARIZATION strategy has already consolidated plus the raw tail of the branch if that consolidation hasn't caught up yet. The tool that decides "this needs a human" never has to know how a handoff document gets built.
Billing and Returns share one rule: never compute a refund amount in the model's head. A 15% restocking fee past a 14-day window, a 30-day eligibility cutoff, a final-sale exception — that's exactly the kind of arithmetic-with-conditionals an LLM will occasionally get subtly wrong, silently, with total confidence. So it runs as a short Python snippet inside AgentCore Code Interpreter, a fresh microVM per call:
@tool
def calculate_refund(order_total_usd, items_returned_value_usd,
days_since_delivery, restocking_fee_pct=0.0) -> str:
code = REFUND_CODE_TEMPLATE.format(...) # plain arithmetic, no policy judgment
with code_session(REGION) as client:
result = client.invoke("executeCode", {"code": code, "language": "python"})
return json.dumps(json.loads(_extract_stdout(result)))
The eligibility decision (is this order even in the return window) comes from a separate Gateway/Lambda tool reading the actual delivered date; the sandbox only ever computes the dollar amount from numbers the model has already been given. Splitting "decide" from "compute" is what makes both halves individually auditable — you can trace exactly which policy branch fired and exactly what arithmetic ran, as two separate spans.
"Is there a shipping delay" is the one question in this workshop that doesn't have a clean API to call, because real carriers don't publish one for a workshop's benefit. That's the actual justification for AgentCore Browser here, not a contrived example: Shipping drives a real Chromium session, over CDP, against UPS/FedEx/USPS's own public service-alerts pages, and reads whatever they say right now.
with browser_session(REGION) as client:
ws_url, headers = client.generate_ws_headers()
with sync_playwright() as playwright:
browser = playwright.chromium.connect_over_cdp(ws_url, headers=headers)
page.goto(url, wait_until="domcontentloaded")
text = page.inner_text("body") # best-effort scrape, not structured data
The eval task for this tool grades on whether it was called, not on what the page said — a real carrier's alert banner is outside anyone's control on any given day. That's a deliberate, stated trade-off in the eval harness, not an oversight.
Order and refund data lives behind a Lambda; the carrier status API is described by a plain OpenAPI schema. Neither specialist calls either directly — both are declared as BeaconGateway targets, and Gateway turns them into MCP tools the agents pick up through one shared MCP client:
agentcore add gateway-target --name OrderRefundLookup --gateway BeaconGateway \
--type lambda-function-arn --lambda-arn <arn> --tool-schema-file gateway/order_lambda/tool-schema.json
agentcore add gateway-target --name CarrierStatusApi --gateway BeaconGateway \
--type open-api-schema --schema gateway/carrier_openapi.json \
--outbound-auth oauth --credential-name CarrierApiOAuth
The Lambda target needs a tool-schema file because a Lambda's input/output shape isn't otherwise discoverable; the OpenAPI target needs nothing extra — Gateway derives the tool list from the spec itself, including the outbound OAuth credential the carrier API requires, held by AgentCore Identity and never written into agent code. Inbound auth is the mirror image: Beacon's own Runtime is configured with a CUSTOM_JWT authorizer, so only verified customer sessions reach it in the first place.
| Concern | The old way (Atlas/StockPilot) | Beacon |
|---|---|---|
| Expose a backend as tools | Hand-written tool functions calling an internal API | agentcore add gateway-target derives tools from a Lambda schema or an OpenAPI spec |
| Hold the outbound credential | Environment variable, or a Secrets Manager call in code | AgentCore Identity, via --outbound-auth oauth --credential-name ... |
| Gate who can invoke the agent | Not modeled — left to whatever fronted the Lambda | authorizerType: CUSTOM_JWT on the Runtime itself |
Six tasks, one suite, run against both a single-agent coordinator and the full multi-agent one: an order-status lookup, refund-eligibility math, a live carrier-delay check, an escalation trigger, memory recall of a seeded preference, and the one cross-domain ticket that touches both Shipping and Returns.
# single-agent vs. multi-agent, side by side
uv run --project app/beacon python evals/run.py --compare
Five of those six tasks are built to pass identically in both modes — dispatch_specialists is available as a tool either way, it just runs its chosen specialists one after another instead of concurrently when multi-agent mode is off, with no memory branch forked. Only the cross-domain task is expected to show a difference, and it's a timing one: concurrent specialists finish faster than sequential ones. Grading tool-call presence rather than tool-output correctness for the two AWS-dependent tools (refund math via Code Interpreter, carrier lookup via Browser) was a deliberate choice too — those sandboxes swallow their own connectivity errors into an AGENTCORE_ERROR string instead of raising, so "was it called" stays a stable signal even against a live but momentarily unreachable service.
Local iteration is two terminals, no separate invoke flag to remember:
# terminal 1
agentcore dev --logs
# terminal 2
agentcore dev "What's the status of order NW-10042?" --stream
Deploying is one command; CDK synthesizes the whole stack — Runtime, Memory, Gateway, both gateway targets — from agentcore.json and creates the execution role with exactly what those declared resources need:
agentcore deploy
agentcore invoke "What's the status of order NW-10042?"
agentcore traces list
Code Interpreter and Browser use the AWS-managed default sandbox rather than a customer-owned one, so nothing in agentcore.json declares them as a project resource — which means CDK doesn't know to grant them. That's the single inline policy this repo's README asks you to add by hand after your first deploy; everything else is automatic.
Every Runtime invocation and Gateway tool call is already an OpenTelemetry span in CloudWatch — agentcore logs and agentcore traces list read from the same place the console does. Teardown mirrors setup: agentcore remove all && agentcore deploy tears down everything AgentCore-native, and bin/teardown.py deletes the one thing the CLI never touched — the DynamoDB order table and its Lambda.
Beacon ships as the same kind of hands-on workshop as its predecessors: a starter/ tree — a full copy of the reference agent with eight functions stubbed — and a complete implementation to diff against.
The throughline across all three of these workshops hasn't changed: every piece — a sub-agent, a memory branch, a gateway target, a skill — is something you can add, version, test, and reason about on its own. What's different here is who's holding the provisioning pen. Atlas and StockPilot proved the architecture is worth the composition tax. Beacon is the proof that, with the current tooling, you no longer have to hand-write the plumbing to get it.
The full repository — the coordinator, three specialists, the memory hooks, the Gateway targets, the eval harness, the Streamlit UI, and the CLI-driven agentcore.json — is on GitHub: github.com/laghao/beacon-support-agentcore. Runs entirely on Amazon Bedrock + AgentCore. MIT licensed.