Take AWS's full-stack AgentCore template, rip out the sample tool, and drop in a real use case: a personal-investing agent with three deterministic financial tools, an MCP gateway, Cedar authorization, and a Cognito-authenticated React UI. This is the engineering story — the auth chain, the design calls, and two production bugs that each ate an afternoon.
Here's a portfolio that looks diversified and isn't. A German retail investor on Trade Republic holds 60% in an S&P 500 ETF and 40% in NVIDIA. They believe they're spread across 500 companies with a growth tilt. In reality the tracker is roughly 6% NVIDIA by weight, so their true single-name exposure to NVIDIA is about 64% — and their exposure to the mega-cap tech cluster (Apple, Microsoft, NVIDIA, Amazon, Google) is higher still. One bad quarter for US tech and the "diversified" portfolio moves like a single stock.
That gap is invisible unless something decomposes the fund. And it's one of three interlocking jobs a personal-investing assistant has to do, each a different kind of work:
Pour all three into one prompt with a general-purpose "run some code" tool attached and you hit the failure mode every agent engineer knows: unreliable tool selection, arithmetic you can't audit, and tax logic buried in a model's head where no reviewer can find it. Gobler's whole design is a reaction to that. The interesting engineering isn't the LLM — it's the substrate the LLM sits on, and where the boundaries are drawn.
Gobler is a portfolio assistant, not a regulated adviser. Every action that would place a trade is a proposal marked requires_approval: true — it never executes. The §23 EStG handling surfaces holding-period status for review; it is informational, not tax advice.
Gobler didn't start from an empty directory. It started from AWS's open-source Fullstack AgentCore Solution Template (FAST) — a CDK stack that wires up an AgentCore Runtime, a Gateway, Memory, Cognito auth (both human and machine-to-machine), a Cedar policy engine, and a Cognito-authenticated React chat UI on Amplify Hosting. Out of the box it ships one demo tool: a text-analysis Lambda behind the gateway.
The task looked deceptively simple: "remove the sample tool, add three financial tools." The first thing I did was not write code — it was reconcile the brief against the repo. The brief assumed things that weren't true, and building on those assumptions would have produced code that didn't deploy:
| The brief assumed | What the code actually was |
|---|---|
| Two OOTB tools: "Text Analysis" and "Code Interpreter" | One gateway Lambda. The Code Interpreter isn't a gateway tool at all — it's an in-process Strands tool wired directly into the agent. |
Edit backend-stack.ts | No such file. The gateway wiring lives in backend-construct.ts. |
Uses CfnGatewayTarget (L1) | Uses the L2 gateway.addLambdaTarget() construct. |
| Register targets, tools appear in the UI | A Cedar policy hard-codes the tool's action name. Remove the sample tool without updating it and the deploy fails. |
This is the unglamorous half of "vibe-coding on a template": the velocity is real, but a template is an opinionated set of assumptions, and the assumptions are load-bearing. The Cedar dependency in particular was a landmine the brief never mentioned — it turned a "delete one tool" task into "delete one tool and rewrite the authorization policy in lockstep." Reading the substrate before touching it is the whole job.
The shape is a single Strands agent on AgentCore Runtime, three tool Lambdas behind an MCP Gateway, a Cedar policy engine deciding who can call what, and a React UI in front — all inside one AWS account, all authenticated by Cognito.
The single most important design decision in Gobler is that the financial analysis is not done by the LLM. The model decides when to call look-through exposure and why; it does not compute the numbers. Each tool is a plain Python Lambda with a typed input schema, deterministic logic, and a JSON result. The look-through score, for instance, is a Herfindahl-Hirschman Index over decomposed issuer weights, minus fixed penalties per concentration flag — the same input always yields the same score.
def analyze_exposure(holdings, concentration_threshold=0.10):
holdings = _normalise_weights(holdings)
issuer = defaultdict(float)
for h in holdings:
for sym, w, sec, reg in _decompose(h): # ETF → constituents
issuer[sym] += w # aggregate TRUE weight
hhi = sum(w*w for w in issuer_pct.values()) # concentration index
score = max(0, (1 - hhi)*100 - 5*len(flags) - 8*len(clusters))
This matters for three reasons an agent engineer cares about. Auditability: a regulator or a suspicious user can read the scoring function; they cannot read a prompt's reasoning. Testability: the tool has a golden-input test that runs with zero AWS dependencies and zero model calls — I smoke-tested all three tools locally in a plain Python REPL before ever deploying. Reliability: the model can hallucinate which tool to call, but it cannot hallucinate NVIDIA's weight inside the S&P 500.
If a step must be exact, reviewable, or reproducible, make it a deterministic tool and let the LLM orchestrate — not compute. The model is the router and the explainer. The Lambda is the calculator. Keeping that boundary sharp is what makes an agent you'd actually trust with money.
The one place a general-purpose sandbox survives is the in-process AgentCore Code Interpreter, kept for genuinely ad-hoc math a user might ask for ("what's my portfolio's return if NVDA drops 15%?"). It's the escape hatch, not the primary path.
A detail that surprised me pleasantly: the agent has no static list of its tools. It doesn't import them, doesn't name them, doesn't know how many there are. On every turn it opens an MCP connection to the Gateway and asks tools/list, then hands whatever comes back to the model.
def create_gateway_mcp_client(user_id: str) -> MCPClient:
gateway_url = get_ssm_parameter(f"/{stack}/gateway_url")
return MCPClient(lambda: streamablehttp_client(
url=gateway_url,
headers={"Authorization": f"Bearer {get_gateway_access_token(user_id)}"},
), prefix="gateway")
The consequence for how you extend the system is significant: adding a fourth tool is an infrastructure change, not an agent change. You write a Lambda, register it as a gateway target in CDK, add its action to the Cedar policy, and deploy. The agent code is untouched — it discovers the new tool automatically on the next turn. The frontend is untouched too; it has no notion of a tool list, it just renders whatever the agent streams back.
This is the payoff of the MCP-gateway indirection. In a naive agent, tools are Python functions imported at build time, and adding one means editing, testing, and redeploying the agent container. Here the tool inventory is runtime state, governed by infrastructure and policy. It's more moving parts, but it's the difference between a demo and something a platform team can operate.
The single hardest thing to hold in your head — and the thing tutorials skip — is how a click in the browser becomes an authorized tool call. There are two token exchanges and a claim injection, and if any link is wrong the failure is silent. Here's the whole chain:
department claim is custom — invented by the Pre-Token Lambda — and it's what Cedar authorizes on.The subtle part is step 4. The machine-to-machine token from a raw client_credentials grant has no department claim. The agent's request carries the user's verified ID as client metadata; a Cognito V3 Pre-Token Lambda reads it and injects department (defaulting to guest) into the M2M access token. That injected claim is the entire basis for authorization downstream. Miss it, and the gateway happily returns an empty tool list — no error, just nothing — because Cedar is deny-by-default and there's no claim to permit on.
The direct gateway test script requests a plain M2M token with no user metadata, so it always sees tools: []. That looks exactly like a broken deployment. It isn't — it's the auth chain working as designed, filtering out every tool for a principal with no department. Knowing which path injects the claim is the difference between "it's broken" and "it's fine, you tested the wrong door."
Who can call which tool isn't hard-coded in the agent or the Lambda — it's a Cedar policy the gateway evaluates on every call. Cedar is AWS's policy language (the same engine behind Verified Permissions). The policy reads the department claim as a principal tag and permits the three Gobler tools for finance and guest:
permit(
principal is AgentCore::OAuthUser,
action in [
AgentCore::Action::"exposure___look_through_exposure",
AgentCore::Action::"rebalance___tax_rebalancing",
AgentCore::Action::"deploy___opportunistic_deployment"
],
resource == AgentCore::Gateway::"{{GATEWAY_ARN}}"
)
when {
principal.hasTag("department") &&
(principal.getTag("department") == "finance" ||
principal.getTag("department") == "guest")
};
The action name is the seam that ties everything together: it's <gatewayTargetName>___<toolName>, triple-underscore delimited. That naming convention connects three files that must agree — the CDK target name, the tool's tool_spec.json, and this policy — and it's the source of both production bugs in the next two sections. Change access control by editing this file and redeploying; the change is data, not code.
First full deploy. Everything provisioned cleanly — runtime READY, three gateway targets READY, policy attached. I open the UI, type "hello," and… nothing. No response. No error in the UI. The chat just sits there.
The CloudWatch runtime logs had the real story, and it was a good one:
ValidationException: Value
'gateway_opportunistic-deployment-target___opportunistic_deployment'
at 'toolConfig.tools.2.member.toolSpec.name' failed to satisfy
constraint: Member must have length less than or equal to 64
Strands composes each tool's name for the Bedrock ConverseStream API as {mcpPrefix}_{targetName}___{toolName}. My target name was opportunistic-deployment-target, which made the composed name 66 characters — two over Bedrock's hard limit. And here's the vicious part: Bedrock validates the entire tool config, so one over-long name rejected every request, including "hello." A message that used no tools at all failed because a tool it might never call had a name two characters too long.
The fix was to shrink the target names — opportunistic-deployment-target → deploy, and similarly for the others — which had to happen in two places at once: the CDK target definition and the Cedar action list, because they share that composed name. The user-facing tool names (look_through_exposure, etc.) stayed the same; only the internal target prefix shrank. I left a comment at the CDK call site so the next person doesn't rediscover the wall the hard way.
Composed identifiers inherit every component's length. When a framework concatenates a prefix, a target, and a tool name behind your back, budget for the sum — and remember that platform APIs often validate the whole config, so one bad entry fails everything, not just itself.
My first version of the Cedar policy was, I thought, cleaner: one permit block per tool, three blocks total. The deploy failed inside the policy custom resource:
ValidationException: When parsing the policy statement,
the following errors occurred:
* unexpected token `permit`
Cedar the language happily accepts a policy set — many statements in one document. But the AgentCore create_policy API takes a single statement: definition={"cedar": {"statement": <one-statement>}}. The CDK loader strips comments and concatenates the file into that one string, so my three permit blocks became one string containing three statements — and the parser choked on the second permit keyword, exactly where a single statement should have ended.
The fix is the version you saw above: one permit using action in [ ... ] to authorize all three tools at once. It's a small change, but it encodes a real constraint of the platform that isn't obvious from either the Cedar docs (which describe the full language) or the API docs (which don't dwell on the single-statement limit). The two half-truths only reconcile when you read the custom-resource Lambda that actually calls the API.
Both failures lived in the gap between a general-purpose thing and its constrained embedding: Cedar-the-language vs. one-statement-the-API; tool-names-in-general vs. 64-chars-in-Bedrock. Reading the layer that does the actual call — the CDK construct, the custom-resource Lambda — is where the truth lives.
Both bugs shared one nasty property: they surfaced as silence, not an error. The 64-char failure happened server-side and streamed back an error event the UI didn't render. Digging into the frontend's Strands SSE parser explained why — it handles text, tool-use, message, and lifecycle events, but has no case for the agent's {"status":"error", ...} shape. So a backend exception became a blank chat bubble.
That's a genuine gap worth naming: an agent's error path needs a rendering path too. When your observability is a streaming protocol, an unhandled event type is indistinguishable from "the model had nothing to say." The reliable move was to bypass the UI entirely and read the source of truth:
# the runtime logs never lie — grep them directly
aws logs tail "/aws/bedrock-agentcore/runtimes/<runtime-id>-DEFAULT" \
--region us-west-2 --since 20m --format short \
| grep -iE "error|exception|denied"
Two lines of grep against CloudWatch turned "the agent is broken and I have no idea why" into a precise ValidationException with the exact offending field. The lesson isn't glamorous, but it's the one that saves days: when the pretty layer goes quiet, drop to the layer that can't lie. For AgentCore that's the runtime's OTEL logs in CloudWatch — every turn, every tool call, every stack trace, already there.
Gobler ships as a complete, deployable full-stack app: the Strands agent, three deterministic financial-tool Lambdas, the Cedar authorization policy, the Cognito/M2M auth wiring, the CDK stack, and the React chat UI. One cdk deploy plus one frontend deploy and you're chatting with it in your own account.
The throughline is the one that separates a demo from a system: keep the boundaries sharp. The LLM orchestrates and explains; deterministic Lambdas compute; a Cedar policy authorizes; the gateway indirection makes tools runtime state instead of build-time imports. Each piece is something you can add, version, test, and — when it breaks at 66 characters — reason about on its own.
The analytics ship with illustrative reference data (a small built-in ETF constituent map, static correlation clusters) so the repo runs end to end with zero external dependencies. For real use, wire the tools to a market-data/holdings provider. The §23 EStG treatment is simplified and informational — not tax advice, and Gobler never executes a trade.
The full repository — the agent, the three tools, the Cedar policy, the CDK stack, and the React UI — is on GitHub: github.com/laghao/gobler-wealth-agentcore. Built on AWS's Fullstack AgentCore Solution Template (Apache-2.0); the use case, tools, and this write-up are my own.