AWS Builder Center
TypeScript Agents in Production: Frontend, Database, and Everything Between

TypeScript Agents in Production: Frontend, Database, and Everything Between

Take a TypeScript AI agent from localhost to production. This post walks through streaming with tool call events, separating auth layers (OAuth public, IAM internal), connecting to PostgreSQL with tenant-isolated typed tools, adding guardrails and cost budgets via lifecycle hooks, wiring up OpenTelemetry observability, and persisting sessions with AgentCore Memory. Uses the Strands Agents TypeScript SDK with Amazon Bedrock. Full sample code included.

Senior Developer Advocate

Introduction

If you're building a TypeScript AI agent for production, you need to read this first. Too many agents are being shipped with wide open endpoints, no cost guardrails, no tenant isolation, and little to no real observability. This post fixes exactly those problems.
Starting with a customer support agent, each section progressively adds a production layer. Streaming with tool events, auth separation, a real database with tenant isolation, guardrails, observability, and session management. By the end you'll have a setup you can actually feel good about deploying.
We're using the Strands Agents  TypeScript library, an open source SDK that supports multiple model providers including Amazon Bedrock, OpenAI, and Anthropic. If you're new to Strands, the TypeScript quickstart  will get you up to speed in a few minutes. We'll use Amazon Bedrock for the rest of this post, but the agent code is the same regardless of provider.
This post covers:
  1. The Starting Point. A customer support agent with typed tools
  2. Serving It. Express server with SSE streaming
  3. Deploying to Amazon Bedrock AgentCore. Serverless deployment with the AgentCore CLI
  4. API Gateway and AWS WAF. Authentication, rate limiting, and security layers
  5. Connecting to PostgreSQL. Typed database tools with tenant isolation
  6. Guardrails and Budgets. Tool call limits, timeouts, and cost control
  7. Observability. OpenTelemetry tracing and runtime metrics
  8. Production Hardening. Context management, session persistence, and evals
You can read end to end or jump to the section you need.
To see a the full video on this topic check out this link!

Prerequisites

Before you begin, verify that you have the following:
  • An AWS account  with permissions to create AWS Lambda functions, Amazon API Gateway, Amazon RDS instances, and Amazon Bedrock resources
  • Node.js 20  or later installed
  • Docker  installed (for AgentCore deployment)
  • AWS CLI  configured with credentials
  • Familiarity with TypeScript, Express, and async/await patterns

The Starting Point

Our running example is a customer support agent with three tools: order lookup, product search, and support ticket creation. Let me show you what one of those tools looks like:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// src/tools.ts
import { tool } from '@strands-agents/sdk';
import { z } from 'zod';

export const getUserOrders = tool({
name: 'get_user_orders',
description: 'Get orders for the current authenticated user.',
inputSchema: z.object({
status: z.enum(['pending', 'shipped', 'delivered']).optional()
.describe('Filter by order status. Omit to get all orders.'),
limit: z.number().max(50).default(10)
.describe('Maximum number of orders to return'),
}),
callback: async (input) => {
// Simulated data — we'll swap this for real DB queries in the Postgres section
const orders = [
{ id: 'ORD-001', status: 'shipped', total: 59.99, item: 'Wireless Headphones' },
{ id: 'ORD-002', status: 'delivered', total: 129.00, item: 'Mechanical Keyboard' },
];
let results = orders;
if (input.status) results = results.filter(o => o.status === input.status);
return JSON.stringify(results.slice(0, input.limit));
},
});
Each tool has a Zod schema for typed inputs and a .describe() on every field so the LLM knows what each parameter means. Because we're using Zod, the input parameter in the callback is fully typed. So input.status is 'pending' | 'shipped' | 'delivered' | undefined. You get autocomplete in your IDE and compile-time errors if you access a field that doesn't exist.
The agent factory function wires everything together. It accepts a userId and stores it in appState , a typed key-value store that tools can read but the model never sees. This matters because you want the user ID to come from your authenticated session, not from the agent's reasoning. If the user ID were in the system prompt or conversation history, the model could theoretically be manipulated into querying another user's data. With appState, the model has no idea the user ID exists. The tools read it silently when they need to scope a query.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// src/agent.ts
import { Agent } from '@strands-agents/sdk';
import { httpRequest } from '@strands-agents/sdk/vended-tools/http-request';
import { getUserOrders, getProductInfo, createSupportTicket } from './tools.js';

export function createAgent(userId?: string) {
const agent = new Agent({
systemPrompt: 'You are a helpful customer support assistant for an online store...',
tools: [getUserOrders, getProductInfo, createSupportTicket, httpRequest],
printer: false,
});
if (userId) agent.appState.set('userId', userId);
return agent;
}
The httpRequest tool is one of the Strands vended tools , which are pre-built tools that ship inside the SDK. It also includes notebook for persistent notes, fileEditor for file operations, and bash for shell commands. You import them from @strands-agents/sdk/vended-tools/* and drop them into your tools array alongside your custom tools .
You can find the full tools and agent code in the GitHub repo .

Serving It

You might be tempted to put the agent inside a Next.js API route or a Nuxt server route. That works, but there are practical reasons to keep the agent as a separate service. We'll be putting API Gateway and authentication in front of the agent later, and having it as its own deployable makes that much simpler. You can also scale it independently from your frontend, deploy it to something like AgentCore that requires its own container, and keep your web server focused on serving pages.
So the recommended approach is a separate Express server. The frontend calls it over HTTP POST, and the agent streams responses back using Server-Sent Events (SSE).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// src/index.ts
import express from 'express';
import cors from 'cors';
import { createAgent } from './agent.js';

const app = express();
app.use(express.json());
app.use(cors({ origin: 'http://localhost:3000' }));

app.get('/ping', (_req, res) => {
res.json({ status: 'ok', time: new Date().toISOString() });
});

app.post('/chat', async (req, res) => {
const { message, userId } = req.body;
if (!message) { res.status(400).json({ error: 'message is required' }); return; }

res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.socket?.setNoDelay(true);
res.flushHeaders();

// ⚠️ DEMO ONLY: In production, never accept userId from the request body.
// The userId should come from your authenticated session (e.g., Cognito JWT claims),
// not from user-controlled input. See the API Gateway + Lambda section for the
// production pattern where userId is extracted from the verified Cognito token.
const agent = createAgent(userId || 'demo-user');

for await (const event of agent.stream(message)) {
if (
event.type === 'modelStreamUpdateEvent' &&
event.event.type === 'modelContentBlockDeltaEvent' &&
event.event.delta.type === 'textDelta'
) {
res.write(`data: ${JSON.stringify({ type: 'text', content: event.event.delta.text })}\n\n`);
}
if (event.type === 'beforeToolCallEvent') {
res.write(`data: ${JSON.stringify({ type: 'tool_start', tool: event.toolUse.name })}\n\n`);
}
if (event.type === 'afterToolCallEvent') {
res.write(`data: ${JSON.stringify({ type: 'tool_end', tool: event.toolUse.name })}\n\n`);
}
}

res.write(`data: ${JSON.stringify({ type: 'done' })}\n\n`);
res.end();
});

app.listen(3001, () => console.log('Agent server on http://localhost:3001'));
The Strands stream yields several event types. We're using three: modelStreamUpdateEvent for text deltas (the token-by-token typing effect), beforeToolCallEvent when the agent is about to call a tool, and afterToolCallEvent when it finishes. The tool events let the frontend show indicators like "Using tool: get_user_orders..." And by the way, there are others too, like agentResultEvent which carries metrics (cycle count, token usage, per-tool stats) that we'll use in the observability section.
We can test this with a curl command.
1
curl -N -X POST http://localhost:3001/chat -H "Content-Type: application/json" -d '{"message":"What are my recent orders?"}'
The frontend calls the /chat endpoint over HTTP and parses the SSE events. You can see a full example of a Nuxt frontend consuming this stream in the GitHub repo .

Deploying to Amazon Bedrock AgentCore

Now we need to get this deployed. We'll use Amazon Bedrock AgentCore , a managed serverless environment for hosting AI agents. It handles scaling, memory, session isolation, and supports both IAM and OAuth authentication.
AgentCore Runtime expects your agent to be a Docker container that exposes two HTTP endpoints on port 8080: GET /ping for health checks and POST /invocations for agent calls.
The Express app requires two adjustments: update the routes and add a Dockerfile.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// src/index.ts — adjusted for AgentCore Runtime
import express from 'express';
import { createAgent } from './agent.js';

const app = express();
app.use(express.json());

// Health check (required by AgentCore Runtime)
app.get('/ping', (_, res) => {
res.json({ status: 'Healthy', time_of_last_update: Math.floor(Date.now() / 1000) });
});

// Agent invocation (required by AgentCore Runtime)
// The Lambda forwards { message, sessionId, userId } as a JSON payload.
// We parse it here so the agent has the authenticated userId in appState.
app.post('/invocations', express.raw({ type: '*/*' }), async (req, res) => {
const body = JSON.parse(new TextDecoder().decode(req.body)) as {
message: string;
sessionId?: string;
userId: string;
};

const agent = createAgent(body.userId);
// Optionally restore session history using body.sessionId before invoking
const response = await agent.invoke(body.message);
return res.json({ response });
});

app.listen(8080, () => console.log('AgentCore Runtime server on port 8080'));
1
2
3
4
5
6
7
8
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN npm run build
EXPOSE 8080
CMD ["npm", "start"]
The Bedrock AgentCore CLI handles the rest. Make sure your AWS credentials are configured (aws configure or environment variables) and you've selected a supported region (us-west-2, us-east-1, eu-central-1, or ap-southeast-2):
1
2
3
4
5
6
npm install -g @aws/agentcore

agentcore create # Interactive wizard — select Strands TypeScript, choose memory and timeout
agentcore dev # Local dev server with hot reloading
agentcore deploy # Push to AWS (outputs the agent ARN and endpoint URL)
agentcore invoke # Test the deployed agent
Verify the deployment succeeded by checking the output for the agent runtime ARN. You'll need this ARN in the next section when wiring up API Gateway.
At this point, you could wire the frontend directly to the AgentCore Runtime invoke endpoint. Add Cognito for login, pass the JWT in the Authorization header, and it works. But you probably don't want to.
The agent runtime is now your entire backend. Where does rate limiting go? Where do you put business logic that doesn't need an LLM? What happens when you want to list conversation history without running the agent? It all ends up in the agent code because there's nowhere else for it to go.
So the rule of thumb here is, if a request doesn't need reasoning, don't route it through the agent. Listing conversation history, fetching user profile data, or checking order status with a known order ID are all deterministic operations. Build separate API endpoints for those. The agent handles open-ended queries; your regular backend handles everything else.

Adding a Front Door with API Gateway and AWS WAF

We don't need to change any of the agent code for this. Because we kept the agent as a standalone service behind a standard HTTP contract, we can layer infrastructure in front of it without touching the application.
Amazon API Gateway  with an Amazon Cognito authorizer handles authentication. AWS WAF  sits in front with two rules: a known bad inputs filter and a rate-based rule that blocks IPs exceeding roughly 100 matching requests within AWS WAF's evaluation window. Note that AWS WAF rate limiting is approximate, not an exact quota. Treat the threshold as a guideline, not a hard cap. A Lambda function sits between API Gateway and the agent to bridge the auth mechanisms.
Let me walk through what each layer does:
1
React App -> API Gateway (Cognito) + AWS WAF -> Lambda -> AgentCore Runtime
The Lambda function between API Gateway and the agent is thin. It extracts the user ID from the Amazon Cognito JWT and forwards the request to AgentCore Runtime using IAM authentication:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// lambda/chat/index.ts
import { BedrockAgentCoreClient, InvokeAgentRuntimeCommand } from '@aws-sdk/client-bedrock-agentcore';
import type { APIGatewayProxyEvent } from 'aws-lambda';

const client = new BedrockAgentCoreClient({ region: process.env.AWS_REGION });

export const handler = async (event: APIGatewayProxyEvent) => {
if (!event.body) {
return { statusCode: 400, body: JSON.stringify({ error: 'Missing request body' }) };
}

const { message, sessionId } = JSON.parse(event.body);
const userId = event.requestContext.authorizer?.claims?.sub;

if (!message || !userId) {
return { statusCode: 400, body: JSON.stringify({ error: 'Missing message or unauthorized' }) };
}

const command = new InvokeAgentRuntimeCommand({
agentRuntimeArn: process.env.AGENT_ARN,
payload: new TextEncoder().encode(JSON.stringify({ message, sessionId, userId })),
});

const response = await client.send(command);
const body = new TextDecoder().decode(response.response);

return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body,
};
};
⚠️ The auth gap you need to know about
Using the same Cognito OAuth token for both API Gateway and Bedrock AgentCore creates a security gap. If a user discovers the agent endpoint URL, they can call it directly without passing through API Gateway, which means rate limiting and AWS WAF rules do not apply to those requests.To fix this, use OAuth for the public layer (client to API Gateway) and IAM SigV4 for the internal layer (Lambda to AgentCore Runtime). The user's token only works at the API Gateway. The agent requires IAM credentials that only the Lambda function has. Configure the agent with IAM auth (the default) instead of OAuth, and give the Lambda function's execution role permission to invoke the agent runtime.
On the frontend side, the only change is the URL. Instead of calling the agent directly, you call API Gateway:
1
2
3
4
5
6
7
8
const response = await fetch(`${API_GATEWAY_URL}/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${cognitoToken}`,
},
body: JSON.stringify({ message: userMessage, sessionId }),
});
What about the added latency?
On a warm path, the API Gateway + Lambda layer adds roughly 30-50ms. The Bedrock model call takes 1-5 seconds. So the overhead is about 1-3% of total response time. You're trading negligible latency for rate limiting, AWS WAF protection, and auth separation.

A Note on Streaming

Since November 2025, API Gateway supports response streaming for Lambda proxy integrations. So if you need token-by-token streaming through this architecture, configure your Lambda with RESPONSE_STREAM invoke mode and use awslambda.streamifyResponse(). Chunks flow progressively from your agent through Lambda and API Gateway to the client without buffering. See the API Gateway response streaming docs  for setup details.

Connecting to PostgreSQL

A common pattern is to give the agent a tool that accepts raw SQL. The agent writes the query, the tool runs it. This works in demos, but in production the agent could construct a query that pulls data across users.
So a better approach is to define specific query shapes as typed tools. The agent decides WHAT to query. The tool controls HOW it queries. The user ID comes from appState, not from the agent's reasoning.
Here's the version you don't want:
1
2
3
4
5
6
7
8
9
10
// Don't do this — the agent writes arbitrary SQL
const queryDb = tool({
name: 'query_database',
description: 'Run a SQL query',
inputSchema: z.object({ sql: z.string() }),
callback: async (input) => {
const result = await pool.query(input.sql);
return JSON.stringify(result.rows);
},
});
And here's what you want instead:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const getUserOrders = tool({
name: 'get_user_orders',
description: 'Get orders for the current authenticated user',
inputSchema: z.object({
status: z.enum(['pending', 'shipped', 'delivered']).optional()
.describe('Filter by order status'),
limit: z.number().max(50).default(10)
.describe('Maximum number of orders to return'),
}),
callback: async (input, context) => {
// userId comes from the session, not from the agent
const userId = context?.agent?.appState?.get('userId');
const result = await pool.query(
'SELECT id, status, total, created_at FROM orders WHERE user_id = $1 AND ($2::text IS NULL OR status = $2) LIMIT $3',
[userId, input.status || null, input.limit]
);
return JSON.stringify(result.rows);
},
});
A few things to notice:
  • The Zod schema constrains what the agent can ask for. Status is an enum, limit has a max of 50.
  • .describe() on every field is prompt engineering baked into your schema. It tells the LLM what each parameter means.
  • The query is parameterized. No SQL injection surface.
  • The column list is explicit. No SELECT *.
  • The user ID comes from appState , not from the conversation.
For the network layer, AgentCore Runtime needs VPC connectivity to reach RDS in a private subnet:
1
2
3
4
5
6
7
8
9
10
11
const runtime = new agentcore.Runtime(this, 'MyAgent', {
runtimeName: 'my_agent',
agentRuntimeArtifact: agentcore.AgentRuntimeArtifact.fromAsset(path.join(__dirname, '../agent')),
networkConfiguration: {
networkMode: 'VPC',
networkModeConfig: {
subnets: [subnet1.subnetId, subnet2.subnetId],
securityGroups: [agentSg.securityGroupId],
},
},
});
The security group on the agent allows outbound to the RDS security group on port 5432. The RDS security group allows inbound from the agent security group. Nothing else.
⚠️ Security notes for production* The httpRequest vended tool allows the agent to make HTTP requests to external endpoints. Remove this tool if your use case does not require it, or configure domain restrictions.* Set maximum message length limits on your /chat endpoint to prevent context window overflow and unexpected costs.* In production behind API Gateway, remove the CORS configuration from Express entirely. API Gateway handles CORS for you.
⚠️ Typed tools are not a substitute for database-enforced isolation
The contracted query shapes above prevent the agent from constructing arbitrary queries, but they don't stop a bug in your tool code from leaking data. For true multi-tenant isolation, add PostgreSQL Row-Level Security (RLS) policies scoped to the authenticated user ID. Also consider an audit log table. Every database operation the agent performs should be recorded with the user ID, tool name, and timestamp. Write tools that modify data are good candidates for human-in-the-loop approval via Strands interrupts before execution.

Guardrails and Budgets

The agent can reason freely, but within limits. The Strands TypeScript SDK has lifecycle hooks  that let you intercept tool calls before and after they run.
BeforeToolCallEvent  fires before every tool call. You can set event.cancel to block it. If you pass a string, that string becomes the error message the LLM sees, so the agent can respond to the user gracefully instead of silently failing.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { BeforeToolCallEvent, AfterToolCallEvent } from '@strands-agents/sdk';

const cleanup = agent.addHook(BeforeToolCallEvent, (event) => {
// Block destructive operations
if (event.toolUse.name.startsWith('delete_')) {
event.cancel = 'Destructive operations are not allowed';
return;
}

// Enforce a tool call budget per session
const count = (agent.appState.get('toolCallCount') as number) ?? 0;
agent.appState.set('toolCallCount', count + 1);
if (count > 20) {
event.cancel = 'Tool call budget exceeded for this session';
}
});
addHook() returns a cleanup function. If you've used React's useEffect, this is the same pattern. Add guardrails for a session, remove them when it ends.
AfterToolCallEvent  fires after a tool finishes. If a tool fails, you can set event.retry = true to run it again once:
1
2
3
4
5
agent.addHook(AfterToolCallEvent, (event) => {
if (event.error && !event.retry) {
event.retry = true;
}
});
For request timeouts, agent.cancel()  uses the web-standard AbortSignal. If the agent is still running after 25 seconds, cancel it. If your tools pass cancelSignal to their fetch calls, those cancel too.
1
2
3
4
5
6
7
const timeout = setTimeout(() => agent.cancel(), 25_000);
try {
const result = await agent.invoke(message);
// result.stopReason === 'cancelled' if it timed out
} finally {
clearTimeout(timeout);
}
For cost control, you can route simple queries to a cheaper model and complex ones to a more capable model. In Strands, the model provider is a constructor argument :
1
2
3
4
5
6
7
8
import { BedrockModel } from '@strands-agents/sdk/models/bedrock';

const cheapModel = new BedrockModel({ modelId: 'anthropic.claude-haiku-4-5-20251001-v1:0' });
const smartModel = new BedrockModel({ modelId: 'anthropic.claude-sonnet-4-20250514-v1:0' });

// Route based on query complexity
const model = isSimpleQuery(message) ? cheapModel : smartModel;
const agent = createAgent(userId, { model });
Check the Bedrock pricing page  for current per-token rates. For a demo or light-usage app routing simple queries to Haiku and complex ones to Sonnet, you're looking at single-digit dollars per month on model costs.
Bedrock Guardrails
For content filtering, PII redaction, and topic denial, you can also use Amazon Bedrock Guardrails  natively with guardrailConfig on the BedrockModel. Managed service, no custom code for common safety patterns.

Observability

When an agent call takes 8 seconds instead of 2, you need to know why. Was it the model thinking? A slow tool? Too many reasoning cycles? Without tracing, you're guessing.
The Strands SDK has built-in observability  using OpenTelemetry. One call to setupTracer() auto-instruments every agent.invoke() and agent.stream() call with spans for model requests, tool executions, and agent loop cycles. No manual instrumentation needed.
1
2
3
4
import { setupTracer } from '@strands-agents/sdk/telemetry';

// One call — every invoke/stream is automatically traced
setupTracer({ exporters: { otlp: true, console: !!process.env.TRACE_CONSOLE } });
In development, set TRACE_CONSOLE=1 to see spans in the terminal. In production, there are two levels of observability:
  • AgentCore-provided metrics and logs: Available by default in Amazon CloudWatch, covering invocation counts, error rates, and latency without any extra setup.
  • Full distributed traces: Requires code instrumentation. Add the AWS Distro for OpenTelemetry (ADOT)  collector to your container and call setupTracer() in your app. This gives you the full trace hierarchy in CloudWatch GenAI Observability: agent invocation → loop cycles → model calls → tool executions, all nested and timed.
Enable Transaction Search in CloudWatch to populate the trace view.
Beyond tracing, every AgentResult includes runtime metrics you can log, alert on, or send to the frontend:
1
2
3
4
5
6
const result = await agent.invoke(prompt);

console.log(result.metrics?.cycleCount); // 3
console.log(result.metrics?.totalDuration); // 4521 (ms)
console.log(result.metrics?.accumulatedUsage); // { inputTokens: 1539, outputTokens: 122 }
console.log(result.metrics?.toolUsage); // { get_user_orders: { count: 2, averageTime: 45, successRate: 1.0 } }
If you're streaming, the agentResultEvent carries the same metrics. Send them to the frontend so you can display response time and token usage in your UI, or log them server-side for dashboards:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
for await (const event of agent.stream(message)) {
// ... text and tool events ...

if (event.type === 'agentResultEvent') {
const metrics = event.result.metrics;
send({
type: 'done',
metrics: {
cycles: metrics?.cycleCount,
durationMs: metrics?.totalDuration,
tokens: metrics?.accumulatedUsage,
toolUsage: metrics?.toolUsage,
},
});
}
}
These metrics are what tell you whether a slow response was the model (high duration, low cycle count) or the agent looping too many times (high cycle count, high tool usage). That distinction changes whether you fix the prompt, optimize a tool, or add a cycle budget.

Production Hardening

Context Window Management

Long conversations fill the context window. Without management, the model either errors out or starts dropping early messages silently. The SDK provides two built-in conversation managers to handle this.
SlidingWindowConversationManager  drops the oldest messages when the window fills. It's the default, and for most use cases it's enough:
1
2
3
4
5
import { Agent, SlidingWindowConversationManager } from '@strands-agents/sdk';

const agent = new Agent({
conversationManager: new SlidingWindowConversationManager({ windowSize: 40 }),
});
SummarizationConversationManager  is smarter. It compresses old messages into a summary using an LLM call instead of discarding them. This preserves context that a sliding window would lose, but costs an extra model invocation per trim. Use it when conversation continuity matters more than cost, like multi-session support cases where the user expects the agent to remember earlier context.

Session Persistence

By default, agent state lives in memory and disappears when the process ends. For local development, SessionManager with FileStorage persists conversations to disk:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { Agent, SessionManager, FileStorage } from '@strands-agents/sdk';

const session = new SessionManager({
sessionId: 'user-123',
storage: { snapshot: new FileStorage({ basePath: './sessions' }) },
saveLatestOn: 'invocation',
});

const agent = new Agent({ sessionManager: session });
await agent.invoke("Hello, I'm a new user!");

// Later — restore the session
const resumedAgent = new Agent({ sessionManager: session });
await resumedAgent.invoke('What did I say earlier?');
For production, you want something more durable. AgentCore Memory , a feature of Amazon Bedrock AgentCore, provides managed conversation persistence with both short-term memory (conversation history) and long-term memory (learned preferences, session summaries). The TypeScript integration uses the @aws-sdk/client-bedrock-agentcore SDK to store and retrieve conversation events:
1
2
3
4
5
6
7
8
9
10
11
12
13
import { AgentCoreMemory } from './agentcore-memory.js';

const memory = new AgentCoreMemory({
memoryId: process.env.AGENTCORE_MEMORY_ID!,
actorId: userId,
sessionId: sid,
region: 'us-west-2',
});

const agent = createAgent(userId);
await memory.loadHistory(agent); // Restore previous conversation
await agent.stream(message); // Agent has full context
await memory.saveNewMessages(agent); // Persist new turns
The loadHistory call restores previous messages into the agent before it processes the new request, so the agent has full conversational context. saveNewMessages persists only the new turns added since the last save. You can see the full AgentCoreMemory implementation in the GitHub repo .

Evals Before Rollout

Deploying a new prompt or tool without testing it is how you introduce regressions. Strands Evals  provides evaluators and reporting for testing agent behavior before deployment.
The basic pattern: build a suite of known queries with expected outcomes, run it against the agent, and gate deployment on pass rate. When you change a prompt or add a tool, run evals to catch regressions before they reach users.
1
2
3
4
5
6
7
8
9
10
11
12
13
import { Evaluator, EvalRunner } from '@strands-agents/evals';

const suite = [
{ input: 'What are my recent orders?', expectedTool: 'get_user_orders' },
{ input: 'I need help with a return', expectedTool: 'create_support_ticket' },
];

const runner = new EvalRunner({ agent: createAgent('eval-user') });
const results = await runner.run(suite);

console.log(`Pass rate: ${results.passRate * 100}%`);
// Fail CI if pass rate drops below threshold
if (results.passRate < 0.95) process.exit(1);
Integrate this into your CI/CD pipeline so a bad agent change blocks deployment automatically.

Blue-Green Deployments

AgentCore Runtime supports versioned endpoints, so you can test a new agent version on staging while production stays pinned to a known-good version. When you deploy, AgentCore creates a new version. You can point a staging endpoint at the new version, run your evals against it, and then promote it to production once you're confident. See the AgentCore docs  for details on endpoint versioning.

Bedrock Guardrails

For content filtering, PII redaction, and topic denial at the model level, you can use Amazon Bedrock Guardrails  directly on the model provider. This handles common safety patterns without custom hook code:
1
2
3
4
5
6
7
8
9
10
11
12
import { BedrockModel } from '@strands-agents/sdk/models/bedrock';
import { Agent } from '@strands-agents/sdk';

const model = new BedrockModel({
modelId: 'anthropic.claude-sonnet-4-20250514-v1:0',
guardrailConfig: {
guardrailIdentifier: 'your-guardrail-id',
guardrailVersion: '1',
},
});

const agent = new Agent({ model });

Cleanup

The resources deployed in this post incur AWS charges while running. Bedrock AgentCore, Amazon RDS instances, and API Gateway are the primary cost drivers. Complete the following steps to stop ongoing charges.
⚠️ Warning: The following cleanup steps permanently delete all data stored in RDS databases and other stateful resources. Back up any data you need to retain before proceeding.
Delete the AgentCore Runtime via the CLI:
1
agentcore delete
If you deployed API Gateway, Lambda, or other infrastructure separately, delete those through the AWS Console or CLI. RDS instances with deletion protection enabled will need that flag removed before they can be destroyed.

Conclusion

We started with a customer support agent running on localhost and ended with a production setup that includes serverless deployment, authentication, AWS WAF protection, a real database with tenant isolation, guardrails, observability, and session persistence.
The key patterns to take away:
  • Keep the agent as a standalone service behind a standard HTTP contract. This lets you layer infrastructure without touching application code.
  • Use appState for sensitive data like user IDs. The model should never see authentication context.
  • Define typed tools instead of giving the agent raw database access. The agent decides what to query; the tool controls how.
  • Add guardrails with lifecycle hooks. Tool call budgets, timeouts, and destructive operation blocks are a few lines of code.
  • Use separate auth layers: OAuth for the public API, IAM for internal service calls.
You can find the complete source code, including the Nuxt frontend, Express server, and tools, in the GitHub repo .
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article