A reference implementation of a LangGraph multi-agent application deployed the way it would actually need to ship: with per-tool authorization, gateway-mediated tool access, short-lived identity, full tracing, cost attribution, and network isolation.
The application itself is deliberately simple. It's a research assistant that takes a question, runs a few web searches through a tool, and summarizes the result. The point isn't the app. The point is everything around it: the architecture that turns an agent from a script into something you could reasonably run in front of real users.
A note before you read on: for a single tool like this, the architecture here is overkill, and a plain Lambda would do the job with far less effort. It starts to earn its place once you have many tools, multiple tenants, and a real blast radius to worry about. See When not to use this.
The clearest way to see what the Cedar policy actually does is to run the same query twice and change only one thing: whether the policy permits the tool.
With the Cedar policy attached, the agent's tool call is authorized at the gateway, the search tool fires three times, and the summarizer gets real results to work with. Note tool_call_count: 3 and the populated research findings.
With the policy gone, the same query runs, but the tool call is never authorized. There is no research_tools step in the graph, tool_call_count stays at 0, and the summarizer is left honestly reporting that it has the search queries but none of the results.
That difference is the whole argument: authorization here isn't decorative. Remove it and the agent simply can't act.
A single request travels through the system like this:
- User → Streamlit UI → API Gateway (REST). The front end calls a REST endpoint. The agent is never exposed directly to the browser.
- API Gateway → invoke Lambda (in-VPC). A thin Lambda inside the VPC receives the request and invokes the agent runtime. (AgentCore Runtime isn't triggered directly by API Gateway, so this Lambda is the entry point into the VPC.)
- invoke Lambda → AgentCore Runtime. The LangGraph multi-agent graph runs as an ARM container on Bedrock AgentCore Runtime, in a private subnet.
- Runtime → Cognito. Before calling any tool, the runtime exchanges a Cognito client id and secret for a short-lived JWT.
- Runtime → AgentCore Gateway. The agent calls the gateway (never the tool directly), presenting the JWT. The gateway validates the token, then evaluates the Cedar policy for the requested tool.
- Gateway → Tool Lambda. If the policy permits it, the gateway invokes the tool Lambda through an IAM role.
- Tool Lambda → Serper.dev → Google. The tool performs the actual web search and returns results back up the chain.
Outbound access from the private subnet goes through VPC interface endpoints (PrivateLink) for AWS services, with a NAT gateway only for controlled egress. Throughout the run, traces are sent to Langfuse, where each LLM call carries its tokens and dollar cost.
Six concerns a notebook never forces you to answer, and the approach taken for each:
| # | Concern | Approach |
|---|---|---|
| 1 | Stopping the agent from calling a tool it shouldn't | Per-tool authorization with Cedar policies, evaluated at the AgentCore Gateway. The agent is the principal, the tool is the action, the gateway is the resource. No matching policy means no call. |
| 2 | Reaching a tool safely | All tools sit behind the AgentCore Gateway. The agent calls the gateway, which authenticates and authorizes before invoking the tool Lambda via IAM. |
| 3 | Authenticating without long-lived secrets | A short-lived Cognito JWT, fetched per request and validated at the gateway before any tool logic runs. |
| 4 | Knowing what the agent did | Full tracing with Langfuse: every hop, from supervisor to researcher to tool call to summarizer, as one tree. |
| 5 | Knowing what a query costs | Cost attributed per LLM call in the trace. One research query is roughly $0.0364 end to end, broken down by step. |
| 6 | Where it runs, isolated | AgentCore Runtime as an ARM container in private subnets, reaching AWS over VPC endpoints, with NAT for controlled egress and least-privilege IAM. |
This is the core of the authorization model. The policy permits one specific agent identity to invoke one specific tool, on one specific gateway. Everything not explicitly permitted is denied by default.
permit (
principal == AgentCore::OAuthUser::"<agent-client-id>",
action == AgentCore::Action::"search-tool___serper_search",
resource == AgentCore::Gateway::"arn:aws:bedrock-agentcore:
<region>:<account-id>:gateway/<gateway-name>"
);
Removing this policy is exactly what the denied-run trace above shows: the agent still tries to call the tool, but with nothing permitting it, the call never executes.
.
├── backend/ # LangGraph multi-agent app (supervisor, researcher, summarizer)
│ ├── ... # runs as the AgentCore Runtime container
│ └── Dockerfile # ARM image, pushed to ECR
├── frontend/ # Streamlit UI
├── lambdas/
│ ├── invoke_runtime/ # In-VPC Lambda behind API Gateway; invokes AgentCore Runtime
│ └── serper_search/ # Tool Lambda the gateway calls (Serper.dev search)
├── policies/
│ └── search_app.cedar # The Cedar authorization policy
├── docs/
│ ├── architecture.png # Architecture diagram
│ ├── trace-allowed.png # Langfuse trace: policy allowed, tool runs
│ └── trace-denied.png # Langfuse trace: policy removed, no tool call
├── requirements.txt
└── README.md
- Orchestration: LangGraph (multi-agent: supervisor, researcher, summarizer)
- Model: Amazon Bedrock (
ChatBedrockConverse) - Agent hosting: Amazon Bedrock AgentCore Runtime (ARM container, image on ECR)
- Tool gateway + authorization: Amazon Bedrock AgentCore Gateway with Cedar policies
- Identity: Amazon Cognito (OAuth client credentials, JWT)
- Tool: AWS Lambda calling Serper.dev
- Entry point: Amazon API Gateway (REST) → in-VPC invoke Lambda
- Frontend: Streamlit
- Observability: Langfuse
- Networking: VPC, private subnets, VPC interface endpoints (PrivateLink), NAT gateway, least-privilege IAM
- An AWS account with access to Amazon Bedrock and Bedrock AgentCore in your region
- Bedrock model access enabled for the model you intend to use
- A Cognito user pool and an app client (client credentials flow)
- A Serper.dev API key for the search tool
- A Langfuse project (public and secret keys)
- Docker with buildx (for building the ARM container image)
- AWS CLI configured, plus whatever IaC you prefer (CDK / Terraform / SAM)
- Python 3.11+
These steps are a skeleton. Adjust paths, names, and commands to match your own infrastructure-as-code setup.
-
Deploy the tool Lambda (
lambdas/serper_search) — theserper_searchfunction the gateway will invoke. -
Create the AgentCore Gateway and register the tool Lambda as a target.
-
Configure inbound auth on the gateway to use your Cognito user pool as the identity provider, and add your app client to the allowed clients.
-
Attach the Cedar policy (
policies/search_app.cedar) scoping the agent identity to the search tool. Substitute your own account id, region, and gateway name (see The Cedar policy). -
Build and push the ARM image for the agent runtime (
backend/) to ECR.cd backend docker buildx build --platform linux/arm64 -t <ecr-repo>:latest --push .
-
Deploy the AgentCore Runtime from that image, into your private subnets, with the VPC endpoints and IAM role attached.
-
Deploy the invoke Lambda (
lambdas/invoke_runtime) and the API Gateway REST entry point in front of it. -
Run the Streamlit frontend.
pip install -r requirements.txt streamlit run frontend/app.py
-
Send a query and watch the trace appear in Langfuse.
This is what the two trace screenshots above show, and it's the most convincing thing to try yourself:
- Send a query with the Cedar policy attached. The tool fires;
tool_call_countis greater than 0; you get a real answer. - Remove (or detach) the Cedar policy for the tool.
- Send the same query again. The tool call is no longer authorized;
tool_call_countis 0; the summarizer reports it has the queries but no results. - Re-attach the policy to restore the working behavior.
I started with ADOT (the AWS Distro for OpenTelemetry). For general infrastructure telemetry it's a reasonable choice, but for agent workflows specifically I found Langfuse gave me far more useful signal for far less wiring. Because it's built around LLM calls, the whole multi-agent run shows up as one readable tree, and each model call carries its tokens and its dollar cost without any extra instrumentation. That's where the per-query cost figure comes from.
If your needs are different (for example, you're standardizing on OpenTelemetry across a wider platform), ADOT may still be the right call. This is a preference for this use case, not a universal recommendation.
This is the honest part, and it's worth stating plainly.
For a single tool, this architecture is more than you need. The gateway, the Cedar policies, the JWT exchange, the separate runtime — for one search tool, a plain Lambda would do the same job with a fraction of the moving parts, and it would be easier to reason about.
The reason to reach for something like this is when the cost of getting authorization wrong goes up:
- Many tools, where "the agent can call anything" stops being acceptable.
- Multiple tenants or callers, where different identities should have different tool access.
- A real blast radius, where an agent calling the wrong thing has consequences worth preventing.
At that point, per-tool authorization, gateway-mediated access, and proper isolation stop being overhead and start being the thing that lets you sleep. The goal of this repo is to show what that looks like end to end, so the tradeoff is a deliberate choice rather than a surprise.
MIT. See LICENSE.


