
I built an API with authentication, streaming, and session management using Strands Agents + Lambda + API Gateway
This page has been translated by machine translation. View original
Introduction
Hello, I'm Jinno from the consulting department, and I love supermarkets.
Are you all building AI agents with Strands Agents?
When it comes to deployment destinations for agents built with Strands Agents, AgentCore is the standard choice, but looking at the official documentation, Lambda is also listed as an option.
API Gateway also added response streaming support in November 2025. So I wondered if we could quickly build a streaming-capable agent API with Lambda + API Gateway, and decided to give it a try!
The code for this article is publicly available on GitHub.
The code from the article body is in the v1 branch of the repository. After cloning, you can deploy everything at once with pnpm install && pnpm deploy.
git clone -b v1 https://github.com/yuu551/lambda-api-strands.git
cd lambda-api-strands
pnpm install
pnpm deploy
Prerequisites
The versions verified at the time of writing are as follows. Please refer to the repository's lockfile for exact dependencies.
| Item | Version / Setting |
|---|---|
| Region | us-east-1 |
| Node.js | 24.16.0 |
| Python | 3.13 (AgentCore Memory version only) |
| Package management | pnpm 11.11.0 |
| aws-cdk-lib | 2.261.0 |
| @strands-agents/sdk | 1.10.0 |
| hono | 4.12.31 |
| zod | 4.4.3 |
| @aws-cdk/aws-lambda-python-alpha | 2.262.0-alpha.0 (AgentCore Memory version only) |
| Model | Claude Haiku 4.5 |
Architecture
Here is what we will ultimately build in this article, as a whole.

In the first half, we will only build ① the S3 SessionManager version. The configuration is simple, with just one Lambda, API Gateway, Cognito, and an S3 bucket as resources. In the second half, we will add a second Lambda using AgentCore Memory, but let's start with this minimal configuration first.
The client authenticates with Cognito in advance to obtain an ID token, attaches it to the Authorization header, and calls the API.
Since API Gateway's Cognito authorizer validates the token, there is no need to write authentication logic on the Lambda side.
Why Use Hono
If you just want to stream from Lambda, you could write it directly using awslambda.streamifyResponse.
Using Hono allows you to write streaming processing concisely and handle routing as well. It's also a framework I personally like, so I used Hono this time!
A similar implementation using Hono is also introduced in the following article on our company blog. Please refer to it as needed.
Implementation
This time I implemented it with the following structure! It turned out simpler than expected.
lambda-api-strands/
├── bin/
│ └── app.ts # CDK entry point
├── lib/
│ └── agent-api-stack.ts # Stack definition
├── lambda/
│ └── handler.ts # Hono + Strands Agents (agent body)
├── cdk.json
├── package.json
└── tsconfig.json
Project Setup
First, install dependencies with pnpm.
pnpm init
pnpm add aws-cdk-lib constructs @strands-agents/sdk @aws-sdk/client-s3 hono zod @aws-cdk/aws-lambda-python-alpha
pnpm add -D aws-cdk typescript tsx esbuild @types/node
Lambda Handler
Let's implement a handler combining Hono + Strands Agents + S3 SessionManager.
import { Hono } from 'hono'
import { streamHandle } from 'hono/aws-lambda'
import { streamText } from 'hono/streaming'
import { Agent, BedrockModel, SessionManager, tool } from '@strands-agents/sdk'
import { S3Storage } from '@strands-agents/sdk/storage'
import z from 'zod'
type Bindings = {
event: {
requestContext: {
authorizer: {
claims: { sub: string; email?: string }
}
}
}
}
const SYSTEM_PROMPT = `You are a helpful assistant that responds in Japanese.
For questions that require the current time, use the get_current_time tool to answer accurately.
Keep your answers concise.`
const getCurrentTime = tool({
name: 'get_current_time',
description: 'Returns the current time in Japan Standard Time (JST).',
inputSchema: z.object({}),
callback: () =>
new Date().toLocaleString('ja-JP', { timeZone: 'Asia/Tokyo' }),
})
const app = new Hono<{ Bindings: Bindings }>()
app.post('/invoke', async (c) => {
const { prompt, sessionId } = await c.req.json<{ prompt: string; sessionId?: string }>()
if (!prompt) {
return c.json({ error: 'prompt is required' }, 400)
}
// Get the user-specific sub from Cognito JWT claims
const userSub = c.env.event.requestContext.authorizer.claims.sub
const sessionManager = new SessionManager({
sessionId: sessionId ?? 'default',
// Separate S3 prefix per user
storage: new S3Storage(process.env.SESSION_BUCKET!, {
prefix: `sessions/${userSub}/`,
}),
})
const agent = new Agent({
model: new BedrockModel({ modelId: process.env.MODEL_ID }),
systemPrompt: SYSTEM_PROMPT,
tools: [getCurrentTime],
sessionManager,
printer: false,
})
return streamText(c, async (stream) => {
for await (const chunk of agent.stream(prompt)) {
if (
chunk.type === 'modelStreamUpdateEvent' &&
chunk.event.type === 'modelContentBlockDeltaEvent' &&
chunk.event.delta.type === 'textDelta'
) {
await stream.write(chunk.event.delta.text)
}
}
})
})
export const handler = streamHandle(app)
The Cognito ID token contains a value called sub that uniquely identifies that user. After the API Gateway authorizer validates the token, it decodes the contents and puts them in the Lambda event. This means the Lambda side doesn't need to parse the token itself, and can obtain information about the logged-in user simply by reading c.env.event.requestContext.authorizer.claims.sub.
By embedding this sub at the beginning of the S3 key, the storage location is divided into sessions/{sub}/{sessionId}/..., so even if someone specifies the same sessionId as another person, the path will be different if the sub is different, preventing conversation histories from getting mixed up.
Streaming is handled inside Hono's streamText().
Events have a two-layer structure, with modelContentBlockDeltaEvent inside the outer modelStreamUpdateEvent, and items where delta.type is textDelta are the text increments. Passing these to stream.write() returns them to the client sequentially.
Session management can be achieved by setting S3Storage in the SessionManager. Sessions are identified by the sessionId in the request body, and with the same sessionId, the previous conversation history is automatically restored. No DynamoDB table design or custom session management code is needed. It's easy to implement, which is great.
CDK Stack
Here is the main stack definition.
import * as cdk from 'aws-cdk-lib'
import * as apigateway from 'aws-cdk-lib/aws-apigateway'
import * as cognito from 'aws-cdk-lib/aws-cognito'
import * as iam from 'aws-cdk-lib/aws-iam'
import * as lambda from 'aws-cdk-lib/aws-lambda'
import { NodejsFunction, OutputFormat } from 'aws-cdk-lib/aws-lambda-nodejs'
import * as s3 from 'aws-cdk-lib/aws-s3'
import { Construct } from 'constructs'
const MODEL_ID = 'global.anthropic.claude-haiku-4-5-20251001-v1:0'
export class AgentApiStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props)
// Cognito User Pool
const userPool = new cognito.UserPool(this, 'AgentUserPool', {
selfSignUpEnabled: false,
signInAliases: { email: true },
removalPolicy: cdk.RemovalPolicy.DESTROY,
})
const userPoolClient = userPool.addClient('AgentApiClient', {
authFlows: { userPassword: true },
})
// S3 bucket for session storage
const sessionBucket = new s3.Bucket(this, 'SessionBucket', {
removalPolicy: cdk.RemovalPolicy.DESTROY,
autoDeleteObjects: true,
})
// Lambda (Hono + Strands Agents TypeScript SDK)
const agentFunction = new NodejsFunction(this, 'AgentFunction', {
entry: 'lambda/handler.ts',
handler: 'handler',
runtime: lambda.Runtime.NODEJS_24_X,
architecture: lambda.Architecture.ARM_64,
timeout: cdk.Duration.minutes(5),
memorySize: 512,
environment: {
MODEL_ID,
SESSION_BUCKET: sessionBucket.bucketName,
},
bundling: {
format: OutputFormat.ESM,
banner:
"import { createRequire } from 'module'; const require = createRequire(import.meta.url);",
},
})
sessionBucket.grantReadWrite(agentFunction)
agentFunction.addToRolePolicy(
new iam.PolicyStatement({
actions: ['bedrock:InvokeModel', 'bedrock:InvokeModelWithResponseStream'],
resources: ['*'],
}),
)
// API Gateway (REST API) + Cognito authorizer + streaming
const api = new apigateway.RestApi(this, 'AgentApi', {
restApiName: 'strands-agent-api',
deployOptions: { stageName: 'v1' },
})
const authorizer = new apigateway.CognitoUserPoolsAuthorizer(this, 'AgentApiAuthorizer', {
cognitoUserPools: [userPool],
})
const methodOptions: apigateway.MethodOptions = {
authorizer,
authorizationType: apigateway.AuthorizationType.COGNITO,
}
// POST /invoke: respond with streaming
api.root.addResource('invoke').addMethod(
'POST',
new apigateway.LambdaIntegration(agentFunction, {
responseTransferMode: apigateway.ResponseTransferMode.STREAM,
timeout: cdk.Duration.minutes(5),
}),
methodOptions,
)
new cdk.CfnOutput(this, 'ApiEndpoint', { value: api.url })
new cdk.CfnOutput(this, 'UserPoolId', { value: userPool.userPoolId })
new cdk.CfnOutput(this, 'UserPoolClientId', { value: userPoolClient.userPoolClientId })
}
}
Since NodejsFunction is used, handler.ts is automatically bundled with esbuild. Since the Strands SDK, Hono, and zod are all included in the bundle, no Lambda layers are needed.
To enable streaming, set responseTransferMode to STREAM in LambdaIntegration.
The main configuration values are as follows.
| Configuration Item | Value | Description |
|---|---|---|
| runtime | Node.js 24 | Managed runtime compatible with Lambda streaming |
| architecture | ARM64 | |
| responseTransferMode | STREAM | Enables API Gateway response streaming |
| timeout | 5 minutes | Lambda function timeout. Set longer to accommodate long responses |
| bundling.format | ESM | Bundle format for NodejsFunction |
Deployment
Once implemented, let's try deploying.
pnpm install
npx cdk deploy
✅ AgentApiStack
✨ Deployment time: 61.86s
Outputs:
AgentApiStack.ApiEndpoint = https://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com/v1/
AgentApiStack.UserPoolClientId = xxxxxxxxxxxxxxxxxxxxxxxxxx
AgentApiStack.UserPoolId = us-east-1_xxxxxxxxx
Deployment succeeded! Let's verify the operation.
Operation Verification
Let's actually try it out!
Creating a Test User
Create a test user in Cognito. Here, we use the user pool ID output during deployment.
USER_POOL_ID=us-east-1_xxxxxxxxx
aws cognito-idp admin-create-user \
--user-pool-id $USER_POOL_ID \
--username test@example.com \
--message-action SUPPRESS
aws cognito-idp admin-set-user-password \
--user-pool-id $USER_POOL_ID \
--username test@example.com \
--password 'YourPassword123!' \
--permanent
Verifying Authentication
First, let's try calling it without a token.
curl -X POST "$API_ENDPOINT/invoke" \
-H "Content-Type: application/json" \
-d '{"prompt": "Hello"}'
{"message":"Unauthorized"}
It's properly rejected with a 401!
Verifying Streaming
Obtain a token from Cognito and try calling with streaming.
CLIENT_ID=xxxxxxxxxxxxxxxxxxxxxxxxxx
ID_TOKEN=$(aws cognito-idp initiate-auth \
--auth-flow USER_PASSWORD_AUTH \
--client-id $CLIENT_ID \
--auth-parameters USERNAME=test@example.com,PASSWORD='YourPassword123!' \
--query 'AuthenticationResult.IdToken' \
--output text)
curl -N -X POST "$API_ENDPOINT/invoke" \
-H "Content-Type: application/json" \
-H "Authorization: $ID_TOKEN" \
-d '{"prompt":"List 3 AWS services you like","sessionId":"test-1"}'
Characters were displayed sequentially in the terminal! When I measured the timing of chunk arrivals to verify whether it was truly streaming, the results were as follows.
[+ 1.51s] chunk 1: 'AWSの好きなサービスを3つ'
[+ 1.54s] chunk 2: '挙げる'
[+ 1.55s] chunk 3: 'とす'
(omitted)
[+ 2.95s] chunk 63: '的なサービ'
[+ 2.98s] chunk 66: 'スです。'
--- first: +1.51s, chunks: 74, done: +3.26s
The first chunk arrived about 1.5 seconds after the request, with 74 chunks arriving sequentially!
Verifying Multi-turn Conversation
Let's verify that the S3 SessionManager is working. Try calling twice with the same sessionId.
curl -N -X POST "$API_ENDPOINT/invoke" \
-H "Content-Type: application/json" \
-H "Authorization: $ID_TOKEN" \
-d '{"prompt":"Hello, I am Jinno. My favorite food is curry. Please remember that.","sessionId":"blog-test-1"}'
Hello, Jinno! Nice to meet you.
So your favorite food is curry. Curry is delicious!
Thank you for introducing yourself. I will keep this information in mind during our future conversations.
curl -N -X POST "$API_ENDPOINT/invoke" \
-H "Content-Type: application/json" \
-H "Authorization: $ID_TOKEN" \
-d '{"prompt":"Do you remember my name and favorite food?","sessionId":"blog-test-1"}'
Yes, I remember!
Your name: Jinno
Favorite food: Curry
That's right. I will make use of this information in our future questions and conversations!
Oh, it's properly responding based on the previous conversation!
Looking at the contents of S3, the paths were separated by user based on the Cognito sub.
sessions/94085448-7071-7023-fd3e-d4866412213a/session/blog-test-1/scopes/agent/agent/snapshots/snapshot_latest.json
It has the structure sessions/{sub}/{sessionId}/...! Even if another user calls with the same sessionId, the path changes, so conversation histories are separated per user at the application level.
Lambda is stateless, but multi-turn conversations are achieved thanks to the S3 SessionManager.
The S3 version is working end-to-end now!!
Also Trying the AgentCore Memory Version
Using the SessionManager for AgentCore Memory, you can also easily integrate long-term memory that automatically extracts user preferences and factual information from conversations and makes them available across different sessions.
However, AgentCoreMemorySessionManager for Strands is only available for Python at the time of writing. For that reason, I added a separate Python Lambda using FastAPI + Lambda Web Adapter in addition to the Hono (TypeScript) one. Only this part changes languages, but if you want to stream from Lambda while also using AgentCore Memory, the current approach is to start from Python.
Creating AgentCore Memory Resources
AgentCore Memory can be created from CDK, so let's add it to the stack. Three long-term memory strategies are enabled.
import * as agentcore from 'aws-cdk-lib/aws-bedrockagentcore'
// AgentCore Memory (align paths with RetrievalConfig on the Python side)
const memory = new agentcore.Memory(this, 'AgentMemory', {
memoryName: 'lambda_api_strands_memory',
expirationDuration: cdk.Duration.days(7),
memoryStrategies: [
agentcore.MemoryStrategy.usingUserPreference({
strategyName: 'UserPreference',
namespaces: ['/preferences/{actorId}/'],
}),
agentcore.MemoryStrategy.usingSemantic({
strategyName: 'SemanticFacts',
namespaces: ['/facts/{actorId}/'],
}),
agentcore.MemoryStrategy.usingSummarization({
strategyName: 'SessionSummary',
namespaces: ['/summaries/{actorId}/{sessionId}/'],
}),
],
})
const cfnMemory = memory.node.findChild('Memory') as agentcore.CfnMemory
cfnMemory.applyRemovalPolicy(cdk.RemovalPolicy.DESTROY)
The {actorId} in the namespace is a placeholder that is automatically replaced at request time. Since we will pass Cognito's sub as actor_id on the Python side later, memory is separated per user.
Python Lambda Handler
Here is the handler for FastAPI + Strands Python SDK + AgentCore Memory SessionManager.
import json
import os
from datetime import datetime, timedelta, timezone
from bedrock_agentcore.memory.integrations.strands.config import (
AgentCoreMemoryConfig,
RetrievalConfig,
)
from bedrock_agentcore.memory.integrations.strands.session_manager import (
AgentCoreMemorySessionManager,
)
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel
from strands import Agent, tool
SYSTEM_PROMPT = """You are a helpful assistant that responds in Japanese.
For questions that require the current time, use the get_current_time tool to answer accurately.
Keep your answers concise.
"""
app = FastAPI()
@tool
def get_current_time() -> str:
"""Returns the current time in Japan Standard Time (JST)."""
jst = timezone(timedelta(hours=9))
return datetime.now(jst).strftime("%Y-%m-%d %H:%M:%S")
class InvokeRequest(BaseModel):
prompt: str
session_id: str | None = None
def _get_user_sub(request: Request) -> str | None:
"""Returns the Cognito sub. Returns None if it cannot be obtained (caller handles 401)."""
# LWA (response streaming mode) passes requestContext in x-amzn-request-context
raw = request.headers.get("x-amzn-request-context")
if not raw:
return None
try:
ctx = json.loads(raw)
sub = ctx.get("authorizer", {}).get("claims", {}).get("sub")
return sub if isinstance(sub, str) and sub else None
except (json.JSONDecodeError, TypeError, AttributeError):
return None
@app.post("/invoke-memory")
async def invoke_memory(req: InvokeRequest, request: Request):
# actor_id is the user isolation boundary for Memory, so do not fall back to a shared ID
user_sub = _get_user_sub(request)
if user_sub is None:
return JSONResponse({"message": "Unauthorized"}, status_code=401)
session_id = req.session_id or "default"
config = AgentCoreMemoryConfig(
memory_id=os.environ["MEMORY_ID"],
session_id=session_id,
actor_id=user_sub,
retrieval_config={
"/preferences/{actorId}/": RetrievalConfig(top_k=5, relevance_score=0.5),
"/facts/{actorId}/": RetrievalConfig(top_k=10, relevance_score=0.3),
"/summaries/{actorId}/{sessionId}/": RetrievalConfig(
top_k=3, relevance_score=0.5
),
},
)
session_manager = AgentCoreMemorySessionManager(
config, region_name=os.environ.get("MEMORY_REGION", "us-east-1")
)
agent = Agent(
model=os.environ["MODEL_ID"],
system_prompt=SYSTEM_PROMPT,
tools=[get_current_time],
session_manager=session_manager,
callback_handler=None,
)
async def generate():
# Ensure flushing even if an exception or client disconnection occurs midway
try:
async for event in agent.stream_async(req.prompt):
if "data" in event:
yield event["data"]
finally:
session_manager.close()
return StreamingResponse(generate(), media_type="text/plain; charset=utf-8")
The structure is similar to the Hono version, but there are a few differences.
For streaming, agent.stream_async() is used. It is an asynchronous generator equivalent to TypeScript's agent.stream(), with text increments coming in the data key of events.
For user isolation, simply pass Cognito's sub as actor_id in AgentCoreMemoryConfig. In the Hono version, we manually embedded the sub in the S3 prefix, but AgentCore Memory automatically replaces {actorId} in the namespace.
Note that the method for obtaining JWT claims differs from the Hono version.
In Lambda Web Adapter's streaming mode, API Gateway's requestContext is passed as JSON in the x-amzn-request-context header. With Hono, you could extract the event via c.env.event, but with FastAPI via LWA, you extract it from the request header.
Adding to CDK
Add a Python Lambda to the CDK stack. Using PythonFunction from @aws-cdk/aws-lambda-python-alpha automatically bundles dependencies from requirements.txt via Docker. It feels the same as NodejsFunction on the TypeScript side.
Add @aws-cdk/aws-lambda-python-alpha to the imports.
import * as python from '@aws-cdk/aws-lambda-python-alpha'
CDK additions (append to agent-api-stack.ts)
// Python Lambda(FastAPI + Strands Python SDK + AgentCore Memory)
const lwaLayer = lambda.LayerVersion.fromLayerVersionArn(
this,
'LwaLayer',
`arn:aws:lambda:${this.region}:753240598075:layer:LambdaAdapterLayerArm64:25`,
)
const memoryFunction = new python.PythonFunction(this, 'MemoryFunction', {
entry: 'lambda/memory',
index: 'main.py',
runtime: lambda.Runtime.PYTHON_3_13,
architecture: lambda.Architecture.ARM_64,
timeout: cdk.Duration.minutes(5),
memorySize: 512,
layers: [lwaLayer],
environment: {
MODEL_ID,
MEMORY_ID: memory.memoryId,
MEMORY_REGION: this.region,
AWS_LAMBDA_EXEC_WRAPPER: '/opt/bootstrap',
AWS_LWA_INVOKE_MODE: 'response_stream',
PORT: '8000',
},
})
// PythonFunction generates the handler in Python module format,
// but LWA uses run.sh as the entry point, so override it with L1
const cfnMemoryFunction = memoryFunction.node.defaultChild as lambda.CfnFunction
cfnMemoryFunction.addPropertyOverride('Handler', 'run.sh')
memoryFunction.addToRolePolicy(
new iam.PolicyStatement({
actions: ['bedrock:InvokeModel', 'bedrock:InvokeModelWithResponseStream'],
resources: ['*'],
}),
)
memory.grantFullAccess(memoryFunction)
// Add POST /invoke-memory endpoint
api.root.addResource('invoke-memory').addMethod(
'POST',
new apigateway.LambdaIntegration(memoryFunction, {
responseTransferMode: apigateway.ResponseTransferMode.STREAM,
timeout: cdk.Duration.minutes(5),
}),
methodOptions,
)
Set the resource ID of the Memory created with CDK in the MEMORY_ID environment variable.
PythonFunction detects the requirements.txt in the directory specified by entry and runs pip install inside a Docker container during cdk synth. Docker also resolves native wheels for ARM64.
Testing the AgentCore Memory Version
After deploying, let's hit the /invoke-memory endpoint.
curl -N -X POST "$API_ENDPOINT/invoke-memory" \
-H "Content-Type: application/json" \
-H "Authorization: $ID_TOKEN" \
-d '{"prompt":"こんにちは、私は神野です。TypeScriptが好きで、趣味はスーパーマーケット巡りです。覚えてね。","session_id":"ltm-test-1"}'
Hello, Kamino-san! Nice to meet you.
I will remember the following about you:
- Name: Kamino-san
- Favorite programming language: TypeScript
- Hobby: Visiting supermarkets
What a wonderful hobby! I will keep this information in mind to be helpful in future conversations.
Verifying memory within the same session works the same as with the S3 version.
Wait a moment for the long-term memory extraction to finish, then call it with a different sessionId.
curl -N -X POST "$API_ENDPOINT/invoke-memory" \
-H "Content-Type: application/json" \
-H "Authorization: $ID_TOKEN" \
-d '{"prompt":"私について何か知ってることある?","session_id":"ltm-cross-session"}'
Yes, I know some things about you, Kamino-san!
- Name: Kamino-san
- Favorite language: TypeScript
- Hobby: Visiting supermarkets
TypeScript is indeed a great language. It has type safety and good development efficiency.
Also, I think visiting supermarkets is a wonderful hobby where you can discover unique products and local food culture from various places!
It's a session ID being used for the first time, yet it remembers the information shared in the previous conversation!!
AgentCore Memory was automatically extracting the user's preferences from the conversation in the background, and records like the following were saved!
{"context":"The user explicitly stated in their self-introduction that they like TypeScript.",
"preference":"Likes TypeScript",
"categories":["Programming","Technology"]}
{"context":"The user explicitly stated in their self-introduction that their hobby is visiting supermarkets.",
"preference":"Hobby is visiting supermarkets",
"categories":["Hobbies","Shopping"]}
How to Choose Between AgentCore and Other Options
The Strands Lambda deployment guide states that the Lambda example in the guide does not implement streaming, and lists Fargate as an option when streaming is needed. However, as I tested this time, combining API Gateway's streaming support with Hono allows you to build a streaming API even with Lambda.
If you want to integrate with an existing API infrastructure, use the full features of API Gateway such as Cognito, WAF, and usage plans, or keep costs down with per-request billing, Lambda + API Gateway seems advantageous. The minimal version using S3 sessions feels like it can be implemented relatively easily. It's also possible to combine API Gateway with AgentCore, but since they can't be natively integrated and a proxy Lambda is required, I felt that was a slight drawback.
On the other hand, if you need long-running tasks that exceed 15 minutes or session isolation via microVMs, you should consider AgentCore. Using the AgentCore CLI, you can go all the way from scaffolding an agent to deployment in one go, so I think it's also approachable for cases where you just want to get something working first. It felt like it depends on the case, but if you just want to build an AI agent and try it out, it might be worth trying AgentCore Runtime or Harness.
I Also Built a Chat UI as a Bonus
I went ahead and built a comparison chat UI as well!
It's better to be able to use it full-stack, and the chat UI is included in the main branch.

It's a configuration that delivers a React + Vite SPA via S3 + CloudFront.

The endpoint selector in the upper left of the screen lets you switch between the S3 SessionManager version and the AgentCore Memory version on the same screen. In the screenshot, when asking "Do you remember me?" on the AgentCore Memory version, it answers with interests automatically extracted from past conversations!
If you switch the sessionId with "New Conversation" and ask the same question, you can click through the comparison from the main content in the UI — the S3 version forgets while the AgentCore Memory version remembers.
The handler in the main content returned text as-is, but since I wanted to show the tool call process in the UI, I changed the response to SSE. Since what agent.stream() returns is an internal event object of the SDK, the main content as-is is sufficient if you just want to stream text.
The final directory structure ended up like this.
lambda-api-strands/
├── bin/app.ts
├── lib/
│ ├── agent-api-stack.ts # API GW + Lambda + Cognito + CloudFront
│ └── model-catalog.ts # Haiku / Sonnet switching definition
├── lambda/
│ ├── handler.ts # Hono + S3 SessionManager (SSE)
│ ├── validate-request.ts
│ └── memory/main.py # FastAPI + AgentCore Memory (SSE)
├── frontend/ # React + Vite chat UI
│ ├── public/app-config.json # Cognito / endpoint / model definitions
│ └── src/
│ ├── components/ # Header, ChatMessage, EndpointSelector ...
│ ├── hooks/useChat.ts # Manages sending and stream state
│ └── lib/sse.ts # SSE parser (shared between TS and Python versions)
└── package.json
The setup instructions are summarized in the README. pnpm deploy runs everything in one go from building the frontend to CDK deployment, so if you want to try it out, please refer to that!
If you have any feedback, please let me know via an Issue!
Closing
I wanted to try deploying on Lambda too, so I gave it a shot. It seems like there are cases where you might choose this kind of configuration rather than just AgentCore.
I hope this article was helpful in some way. Thank you for reading to the end!
Supplementary Notes
Notes on API Gateway Response Streaming
When streaming is enabled, some behaviors change. API Gateway calls Lambda using the InvokeWithResponseStream API, so the Lambda side needs to return in a format equivalent to streamifyResponse (Hono handles this internally). The integration timeout can now be extended up to 15 minutes (the default remains 29 seconds), the first 10 MB of the response has no bandwidth limit, and subsequent data is limited to 2 MB/s. Billing is counted per 10 MB unit of response payload.
Session Structure of S3 SessionManager
Session data is saved in S3 with this structure. Since the JWT sub is included as a prefix, history is separated per user at the application level.
sessions/<userSub>/session/<sessionId>/
└── scopes/
└── agent/
└── <agentId>/
└── snapshots/
└── snapshot_latest.json
SessionManager overwrites snapshot_latest.json every time an invocation completes. When the next request comes in with the same sessionId, it automatically restores the snapshot and continues the conversation.
How to Delete
Once verification is complete, please delete the entire stack. Since autoDeleteObjects is set on the S3 bucket and RemovalPolicy.DESTROY is set on AgentCore Memory, both will be deleted along with the stack deletion.
npx cdk destroy
