10 views
An IDE agent is not a single HTTP request. It is a multi-turn conversation: plan → tool call → patch preview → user interrupt → continue. HTTP with Lambda response streaming is excellent for one-shot token streams. Multi-turn with interrupts and server-pushed tool events wants API Gateway WebSocket APIs, connectionId state in DynamoDB, and the management callback API — not sticky ALB folklore.
⚡ TL;DR: Use API Gateway WebSockets for multi-turn coding agents. PersistconnectionId ↔ sessionIdin DynamoDB on$connect, clean up on$disconnect, handle messages ondefault(or typed routes). Push tokens/events withPostToConnection. Design for reconnect: client resumes withsessionId, not the oldconnectionId. Pair HTTP streaming for short prompts; use WS for sessions. See also SQS FIFO agent queues and Bedrock Converse idempotent tool results.
API Gateway WebSockets for Multi-Turn Coding: production guidance
API Gateway WebSocket routes:$connect— auth, create session row, storeconnectionId$disconnect— mark offline / TTL; do not delete session history yet$defaultor custom routes (sendmessage,interrupt) — agent turns
typescript
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
DynamoDBDocumentClient,
PutCommand,
DeleteCommand,
UpdateCommand,
} from "@aws-sdk/lib-dynamodb";
import type {
APIGatewayProxyWebsocketHandlerV2,
} from "aws-lambda";
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE = process.env.SESSION_TABLE!;
export const connectHandler: APIGatewayProxyWebsocketHandlerV2 = async (event) => {
const connectionId = event.requestContext.connectionId;
const sessionId =
event.queryStringParameters?.sessionId ?? crypto.randomUUID();
const tenantId = event.queryStringParameters?.tenantId;
if (!tenantId) return { statusCode: 401, body: "missing tenantId" };
await ddb.send(
new PutCommand({
TableName: TABLE,
Item: {
pk: `conn#${connectionId}`,
sk: "meta",
sessionId,
tenantId,
status: "connected",
ttl: Math.floor(Date.now() / 1000) + 86400,
},
})
);
await ddb.send(
new PutCommand({
TableName: TABLE,
Item: {
pk: `session#${sessionId}`,
sk: "conn",
connectionId,
tenantId,
ttl: Math.floor(Date.now() / 1000) + 86400,
},
})
);
return { statusCode: 200, body: "connected" };
};
export const disconnectHandler: APIGatewayProxyWebsocketHandlerV2 = async (event) => {
const connectionId = event.requestContext.connectionId;
await ddb.send(
new DeleteCommand({
TableName: TABLE,
Key: { pk: `conn#${connectionId}`, sk: "meta" },
})
);
// ✅ Keep session# row for resume; clear connectionId pointer
// (lookup session via GSI or store reverse update)
return { statusCode: 200, body: "disconnected" };
};
Fan-out via the callback API (PostToConnection)
Lambda that generates tokens or finishes a tool must push to the client using the WebSocket management endpoint, not by “returning” from a different connection.typescript
Default route handler sketch:
import {
ApiGatewayManagementApiClient,
PostToConnectionCommand,
} from "@aws-sdk/client-apigatewaymanagementapi";
function mgmtClient(domainName: string, stage: string) {
return new ApiGatewayManagementApiClient({
endpoint: `https://${domainName}/${stage}`,
});
}
export async function pushToConnection(input: {
domainName: string;
stage: string;
connectionId: string;
payload: unknown;
}) {
const client = mgmtClient(input.domainName, input.stage);
try {
await client.send(
new PostToConnectionCommand({
ConnectionId: input.connectionId,
Data: Buffer.from(JSON.stringify(input.payload)),
})
);
} catch (e: any) {
// ✅ GoneException → connection dead; clear Dynamo mapping
if (e.name === "GoneException" || e.$metadata?.httpStatusCode === 410) {
await ddb.send(
new DeleteCommand({
TableName: TABLE,
Key: { pk: `conn#${input.connectionId}`, sk: "meta" },
})
);
return { ok: false, reason: "gone" };
}
throw e;
}
return { ok: true };
}
typescript
✅ Put long Bedrock work on SQS/FIFO worker Lambdas; WS Lambdas stay fast. Ordered per-session jobs map cleanly to SQS FIFO + Lambda.
export const defaultHandler: APIGatewayProxyWebsocketHandlerV2 = async (event) => {
const connectionId = event.requestContext.connectionId;
const body = JSON.parse(event.body ?? "{}") as {
type: string;
sessionId: string;
prompt?: string;
interrupt?: boolean;
};
if (body.type === "agent.turn") {
// Enqueue heavy work — do not run 90s Bedrock inside the WS Lambda if concurrency is tight
await enqueueAgentJob({
sessionId: body.sessionId,
connectionId,
prompt: body.prompt,
domainName: event.requestContext.domainName!,
stage: event.requestContext.stage,
});
await pushToConnection({
domainName: event.requestContext.domainName!,
stage: event.requestContext.stage,
connectionId,
payload: { type: "ack", sessionId: body.sessionId },
});
}
if (body.type === "agent.interrupt") {
await flagInterrupt(body.sessionId);
}
return { statusCode: 200, body: "ok" };
};
Lambda concurrency and backpressure
Each WS message can invoke Lambda. A chatty IDE + streaming token pushes can amplify concurrency.- Set reserved concurrency on the WS routers (connect/default) separate from workers.
- Coalesce token deltas (e.g. 50ms batching) before
PostToConnection. - If
PostToConnectionthrottles, buffer in SQS persessionId— do not block Bedrock generation on WS send.
typescript
// ✅ Batch deltas
let buf = "";
let timer: NodeJS.Timeout | null = null;
function queueDelta(text: string, send: (s: string) => Promise<void>) {
buf += text;
if (!timer) {
timer = setTimeout(async () => {
const out = buf;
buf = "";
timer = null;
await send(out);
}, 50);
}
}
❌ Sticky ALB myths
You do not need sticky sessions on an ALB for API Gateway WebSockets. API Gateway owns the connection; your Lambdas are stateless relative to TCP. State lives in DynamoDB (connectionId, sessionId, conversation transcript pointers).
| Myth | Reality |
|---|---|
| “Stick to one Lambda” | Impossible / unnecessary — Lambda is invoke-per-message |
| “Stick to one AZ via ALB cookie” | API Gateway WebSocket API is not that ALB pattern |
| “Store state only in memory” | Reconnect and scale kill you — use DynamoDB |
| “connectionId is forever” | It dies; resume with sessionId |
Resume after reconnect with sessionId
Clients drop (laptops sleep). On reconnect:- Client opens WS with
?sessionId=...&tenantId=... $connectstores newconnectionIdagainst the samesessionId- Worker that had the old id looks up
session# → connbefore each push - Replay missed events from an event log (DynamoDB or SQS) keyed by session
typescript
Use idempotent tool results so a replayed turn does not double-apply patches.
export async function resolveConnectionId(sessionId: string): Promise<string | null> {
const out = await ddb.send(
new UpdateCommand({
// actually Get — shown as helper
TableName: TABLE,
Key: { pk: `session#${sessionId}`, sk: "conn" },
UpdateExpression: "SET lastSeen = :t",
ExpressionAttributeValues: { ":t": Date.now() },
ReturnValues: "ALL_NEW",
})
);
return (out.Attributes?.connectionId as string) ?? null;
}
Pair with HTTP response streaming
| Mode | Use |
|---|---|
| Function URL / HTTP + RESPONSE_STREAM | One-shot prompts, SSE-like token streams, simple clients |
| API Gateway WebSocket | Multi-turn IDE agents, interrupts, server-push tool events |
Checklist
- [ ]
$connectwritesconn#andsession#mappings with TTL - [ ]
$disconnectclears connection mapping; keeps session for resume - [ ] Long Bedrock/tool work on worker queue, not in the WS default Lambda
- [ ]
PostToConnectionhandles 410 Gone by deleting stale conn rows - [ ] Client resumes with
sessionId; never assumesconnectionIdsurvives - [ ] Reserved concurrency split: WS routers vs workers
- [ ] No reliance on ALB sticky myths for API Gateway WS
- [ ] Idempotent tool applies under reconnect replay
Watch: 100 Days of AI on YouTube
Short videos from CheatCoders, one AI engineering topic per day.
Most viewed
- Python Decorators Explained: From Simple Wrappers to Production Patterns
- Claude Code vs Cursor vs GitHub Copilot: Choose by Workflow, and Set Up One Repo That Works With All Three
- AI Agent Frameworks in 2025: LangGraph vs CrewAI vs AutoGen vs Raw API
- Python String Methods: Every str Method With Real Production Examples
- REST API Design Best Practices: The Patterns That Make APIs a Joy to Use
Newly added
- n8n vs AI Agent: I Gave Both the Same Job (Local, Ollama)
- Local AI Video Generator: Build Your Own App (Free, Python, No GPU)
- AI Wrote Raw Binary Instead of Code. Here’s What Happened (WebAssembly)
- I Built a Browser That Only Runs Encrypted Binary (Ep 1)
- RAG From Scratch: Chunking, Embeddings, Reranking and Evaluation (Real Numbers)
Deep-dive PDF
Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.
Already subscribed? or open the subscribe page.
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.