showcase/shell-docs/src/content/docs/deploy/aws-lambda.mdx
The CopilotKit runtime is a Fetch handler, so it runs on Lambda without a server. The one thing that needs care is streaming: chat responses are served as Server-Sent Events, and a front door that buffers turns a live conversation into a long pause followed by the whole reply at once.
| Front door | Streams SSE? | Use it when |
|---|---|---|
Lambda Function URL with InvokeMode: RESPONSE_STREAM | Yes | The default. Nothing sits between the browser and the function. |
API Gateway REST API with responseTransferMode: STREAM | Yes | You need REST API features in front of the runtime — WAF, usage plans, a custom authorizer, or an existing REST API to extend. |
| API Gateway HTTP API | No | Response streaming is REST-only. Buffered: 10 MB cap, 29-second timeout. |
| Application Load Balancer | No | ALB has no streaming path for Lambda targets. |
The handler code below is the same for both streaming paths; only the event shape and the infrastructure config differ.
Response streaming requires three things: a Function URL with its invoke mode set to RESPONSE_STREAM, a handler wrapped in awslambda.streamifyResponse, and a Node.js managed runtime (Node.js 18 or later).
Lambda Function URLs deliver events in payload format 2.0. Convert that into a Request, hand it to the CopilotKit handler, then pipe the Response body into the Lambda response stream.
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import type { LambdaFunctionURLEvent } from "aws-lambda";
import {
CopilotRuntime,
createCopilotRuntimeHandler,
BuiltInAgent,
} from "@copilotkit/runtime/v2";
// Created once per container, reused across warm invocations.
const runtime = new CopilotRuntime({
agents: {
default: new BuiltInAgent({ model: "openai/gpt-4o-mini" }),
},
});
const copilotHandler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
cors: true,
});
function toFetchRequest(event: LambdaFunctionURLEvent): Request {
const method = event.requestContext.http.method;
const host = event.headers.host ?? "localhost";
const query = event.rawQueryString ? `?${event.rawQueryString}` : "";
const hasBody = method !== "GET" && method !== "HEAD" && event.body != null;
return new Request(`https://${host}${event.rawPath}${query}`, {
method,
headers: new Headers(event.headers as Record<string, string>),
body: hasBody
? event.isBase64Encoded
? Buffer.from(event.body!, "base64")
: event.body
: undefined,
});
}
export const handler = awslambda.streamifyResponse(
async (event: LambdaFunctionURLEvent, responseStream) => {
const response = await copilotHandler(toFetchRequest(event));
// Status and headers must be attached before the first byte is written.
const stream = awslambda.HttpResponseStream.from(responseStream, {
statusCode: response.status,
headers: Object.fromEntries(response.headers),
});
if (!response.body) {
stream.end();
return;
}
// Readable.fromWeb bridges the Fetch ReadableStream to a Node stream, so
// SSE chunks reach the client as the agent produces them.
await pipeline(Readable.fromWeb(response.body as any), stream);
},
);
awslambda is a global injected by the managed Node.js runtime, so TypeScript needs to be told it exists:
import type { Writable } from "node:stream";
declare global {
namespace awslambda {
function streamifyResponse<TEvent>(
handler: (event: TEvent, responseStream: Writable, context: unknown) => Promise<void>,
): (event: TEvent, responseStream: Writable, context: unknown) => Promise<void>;
namespace HttpResponseStream {
function from(
stream: Writable,
metadata: { statusCode: number; headers?: Record<string, string> },
): Writable;
}
}
}
export {};
Streaming is off by default. The Function URL must be created with InvokeMode: RESPONSE_STREAM — flipping it later requires updating the URL config, not the function.
<Tabs groupId="iac" items={["AWS CLI", "AWS CDK", "AWS SAM"]}> <Tab value="AWS CLI">
aws lambda create-function-url-config \
--function-name copilotkit-runtime \
--auth-type NONE \
--invoke-mode RESPONSE_STREAM \
--cors '{"AllowOrigins":["https://myapp.com"],"AllowHeaders":["content-type","authorization"],"AllowMethods":["GET","POST"]}'
const fn = new NodejsFunction(this, "CopilotKitRuntime", { entry: "src/handler.ts", runtime: Runtime.NODEJS_22_X, // Long enough for a full agent run; Lambda's ceiling is 15 minutes. timeout: Duration.minutes(5), memorySize: 1024, environment: { OPENAI_API_KEY: process.env.OPENAI_API_KEY!, }, });
fn.addFunctionUrl({ authType: FunctionUrlAuthType.NONE, invokeMode: InvokeMode.RESPONSE_STREAM, cors: { allowedOrigins: ["https://myapp.com"], allowedHeaders: ["content-type", "authorization"], }, });
</Tab>
<Tab value="AWS SAM">
```yaml title="template.yaml"
Resources:
CopilotKitRuntime:
Type: AWS::Serverless::Function
Properties:
CodeUri: ./
Handler: dist/handler.handler
Runtime: nodejs22.x
Timeout: 300
MemorySize: 1024
Environment:
Variables:
OPENAI_API_KEY: !Ref OpenAiApiKey
FunctionUrlConfig:
AuthType: NONE
InvokeMode: RESPONSE_STREAM
Cors:
AllowOrigins: ["https://myapp.com"]
AllowHeaders: ["content-type", "authorization"]
Metadata:
BuildMethod: esbuild
BuildProperties:
Target: node22
Bundle: true
EntryPoints: [src/handler.ts]
The Function URL is the origin; basePath is appended to it.
REST APIs gained response streaming in November 2025. It is per-integration and off by default: setting responseTransferMode to STREAM makes API Gateway invoke the function through InvokeWithResponseStream and forward bytes as they arrive, which also lifts the 10 MB response cap and the 29-second integration timeout that apply to buffered integrations.
Use this path when you want REST API features in front of the runtime. If you don't, the Function URL above is less to configure.
The streaming half of the handler is unchanged — awslambda.streamifyResponse and HttpResponseStream.from emit exactly the metadata-plus-delimiter format API Gateway expects. What changes is the event: a REST proxy integration delivers payload format 1.0, not the 2.0 shape a Function URL sends.
import type { APIGatewayProxyEvent } from "aws-lambda";
// REST proxy integrations deliver payload format 1.0: `httpMethod` and `path`
// rather than `requestContext.http.method` and `rawPath`. `path` excludes the
// stage name, which is what we want — it lines up with the runtime's basePath.
function toFetchRequest(event: APIGatewayProxyEvent): Request {
const method = event.httpMethod;
const headers = new Headers();
for (const [name, value] of Object.entries(event.headers)) {
if (value != null) headers.append(name, value);
}
const query = new URLSearchParams();
for (const [name, values] of Object.entries(
event.multiValueQueryStringParameters ?? {},
)) {
for (const value of values ?? []) query.append(name, value);
}
const search = query.toString();
const hasBody = method !== "GET" && method !== "HEAD" && event.body != null;
return new Request(
`https://${headers.get("host") ?? "localhost"}${event.path}${search ? `?${search}` : ""}`,
{
method,
headers,
body: hasBody
? event.isBase64Encoded
? Buffer.from(event.body!, "base64")
: event.body
: undefined,
},
);
}
The awslambda.streamifyResponse(...) block from the Function URL section works as-is on top of this.
Three settings matter: a Lambda proxy (AWS_PROXY) integration, a URI ending in /response-streaming-invocations, and responseTransferMode: STREAM. Streaming is not supported on non-proxy integration types.
<Tabs groupId="iac" items={["AWS CLI", "AWS CDK", "AWS SAM"]}> <Tab value="AWS CLI"> On an existing integration, patch the URI and the transfer mode together, then redeploy the stage:
aws apigateway update-integration \
--rest-api-id a1b2c3 \
--resource-id aaa111 \
--http-method ANY \
--patch-operations '[
{"op":"replace","path":"/uri","value":"arn:aws:apigateway:us-east-1:lambda:path/2021-11-15/functions/arn:aws:lambda:us-east-1:111122223333:function:copilotkit-runtime/response-streaming-invocations"},
{"op":"replace","path":"/responseTransferMode","value":"STREAM"},
{"op":"replace","path":"/timeoutInMillis","value":"900000"}
]'
aws apigateway create-deployment --rest-api-id a1b2c3 --stage-name prod
Note the API version in the URI path: 2021-11-15/.../response-streaming-invocations, not the 2015-03-31/.../invocations a buffered Lambda proxy integration uses. The permission is unchanged — InvokeWithResponseStream authorizes against lambda:InvokeFunction, so an existing add-permission grant still applies.
</Tab>
<Tab value="AWS CDK">
import { LambdaIntegration, ResponseTransferMode, RestApi, EndpointType } from "aws-cdk-lib/aws-apigateway";
import { Duration } from "aws-cdk-lib";
const api = new RestApi(this, "CopilotKitApi", {
// Regional gets a 5-minute idle timeout; edge-optimized gets 30 seconds.
endpointConfiguration: { types: [EndpointType.REGIONAL] },
});
api.root.addResource("api").addResource("copilotkit").addResource("{proxy+}").addMethod(
"ANY",
new LambdaIntegration(fn, {
responseTransferMode: ResponseTransferMode.STREAM,
timeout: Duration.minutes(15),
}),
);
responseTransferMode needs a recent aws-cdk-lib; the construct sets the streaming invocation URI for you.
</Tab>
<Tab value="AWS SAM">
CopilotKitMethod:
Type: AWS::ApiGateway::Method
Properties:
RestApiId: !Ref CopilotKitApi
ResourceId: !Ref CopilotKitProxyResource
HttpMethod: ANY
AuthorizationType: NONE
Integration:
Type: AWS_PROXY
IntegrationHttpMethod: POST
ResponseTransferMode: STREAM
TimeoutInMillis: 900000
Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2021-11-15/functions/${CopilotKitRuntime.Arn}/response-streaming-invocations
STREAM you can take it up to 15 minutes on Regional and private APIs — match it to the Lambda timeout.STREAM disables buffered-only features on that integration: endpoint caching, content encoding, and VTL response transformation. Compress inside the integration if you need it.Behind an HTTP API or an Application Load Balancer there is no streaming path, so serverless-http adapting the Express handler is as good as it gets. Chat still functions end to end, but every response arrives in one piece at the end of the run.
npm install express serverless-http @copilotkit/runtime
import express from "express";
import serverlessHttp from "serverless-http";
import { CopilotRuntime, BuiltInAgent } from "@copilotkit/runtime/v2";
import { createCopilotExpressHandler } from "@copilotkit/runtime/v2/express";
const runtime = new CopilotRuntime({
agents: {
default: new BuiltInAgent({ model: "openai/gpt-4o-mini" }),
},
});
const app = express();
app.use(
createCopilotExpressHandler({
runtime,
basePath: "/api/copilotkit",
cors: true,
}),
);
export const handler = serverlessHttp(app);
Two constraints to plan around:
responseTransferMode: STREAM lifts on a REST API.Single-route mode pairs well here, since it collapses the runtime to one POST and avoids configuring a catch-all proxy resource:
createCopilotExpressHandler({
runtime,
basePath: "/api/copilotkit",
mode: "single-route",
cors: true,
});
Use the runtime's onRequest hook to reject unauthenticated calls before any routing happens. It works identically on every front door above.
const copilotHandler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit",
cors: true,
hooks: {
onRequest: async ({ request }) => {
const token = request.headers.get("authorization");
if (!token || !(await verifyToken(token))) {
// Throwing a Response short-circuits the handler.
throw new Response("Unauthorized", { status: 401 });
}
},
},
});
For AWS-native auth instead, set the Function URL's AuthType to AWS_IAM and have callers sign requests with SigV4. That moves the check into IAM, but the browser can no longer call the URL directly — you need a signing proxy in between.
See Authentication for forwarding user identity through to your agent.
@copilotkit/runtime is not in any AWS-managed layer. Use NodejsFunction (CDK), BuildMethod: esbuild (SAM), or your own bundler.new CopilotRuntime(...) at module scope means warm invocations reuse it rather than rebuilding it per request.