Deploying Claude‑driven coding assistants on Lambda can feel like squeezing a massive monolith into a tiny zip file. pnpm workspaces prune duplicate files and share a single lockfile, slashing bundle size and cold‑start latency. This post shows how a single package‑manager switch turns a sluggish AI agent into a snappy serverless service.
Why Deployment Size Matters for Claude‑Powered Lambda Functions
When a Lambda function starts, AWS has to download the deployment package (the zip file you upload) into a fresh execution environment. The bigger the zip, the longer the download, and the higher the cold‑start latency—the time it takes before your function can answer the first request.
For Claude‑powered agents the problem is amplified:
- Model SDKs (e.g.,
@aws-sdk/client-bedrock-runtime) already pull in many transitive dependencies. - Utility libraries you write for prompt templating, code formatting, or logging add more files.
- Native binaries (e.g., for image processing) need platform‑specific builds, which duplicate across packages.
Think of the deployment package like a suitcase for a trip. If you pack every piece of clothing you own, the airline will charge you extra, and you'll spend more time waiting at the gate. Packing only the items you actually need lets you board faster and saves money. The same principle applies to Lambda: smaller packages board (download) faster, and you pay less for storage and data transfer.
In plain English: A lean zip file means your Claude assistant starts up quicker, answers users faster, and costs less to run.
Below is a tiny Lambda that just logs its own size. Run it with a bloated node_modules folder and you’ll see the delay in the log output.
// handler.ts – a minimal Lambda that reports its own bundle size import { readFileSync } from 'fs'; import { resolve } from 'path'; // The absolute path to the zip file that Lambda extracts at /var/task const bundlePath = resolve('/var/task', 'package.json'); export const handler = async () => { // Read the package.json to get a rough idea of how many deps we have const pkg = JSON.parse(readFileSync(bundlePath, 'utf8')); console.log('🚀 Starting Claude‑powered Lambda'); console.log(`📦 Bundle reports ${Object.keys(pkg.dependencies || {}).length} direct deps`); console.log(`🗂️ Working directory contains ${Object.keys(require('module').builtinModules).length} built‑ins`); return { statusCode: 200, body: JSON.stringify({ message: 'Hello from Claude!' }) }; }; Each line is commented, showing exactly what the code does.
Introducing pnpm Workspaces: How They Differ from npm and Yarn
pnpm (pronounced “pee‑en‑pee‑em”) is a JavaScript package manager that stores every downloaded package once on disk and then creates hard links into each project that needs it.
Workspace – a group of related packages that share a single
pnpm-lock.yaml. Think of a workspace as a neighborhood where all houses (packages) share the same water pipe (lockfile) and the same grocery store (central store of modules).Deduplication – pnpm never copies the same version of a library into multiple
node_modulesfolders. npm and Yarn used to duplicate files, which inflates the bundle..pnpmdirectory – pnpm writes the actual files into a hidden.pnpmfolder and then links them into each package’snode_modules. This layout is what makes deduplication possible.
A typical workspace layout looks like this:
my-claude-agent/ ├─ pnpm-workspace.yaml # tells pnpm which folders belong to the workspace ├─ package.json # root config, holds shared devDeps ├─ packages/ │ ├─ agent/ # the Lambda handler │ │ ├─ src/ │ │ │ └─ handler.ts │ │ └─ package.json │ └─ shared/ # utilities used by the agent │ ├─ src/ │ │ └─ helpers.ts │ └─ package.json └─ .pnpm/ # pnpm’s internal store (not a source folder) The lockfile (pnpm-lock.yaml) guarantees that every developer and every CI run installs the exact same versions, which is crucial for reproducible builds in serverless pipelines.
Key takeaway: pnpm’s workspace model gives you one place for all dependencies, so the final Lambda zip only contains a single copy of each library instead of many.
Setting Up a Monorepo for Your Claude Agent with Corepack
Corepack is a lightweight wrapper shipped with Node that lets you pin a specific version of a package manager (like pnpm) without installing it globally. This ensures everyone on the team runs the same pnpm version, avoiding subtle “works on my machine” bugs.
Below is a step‑by‑step setup you can copy‑paste into a fresh terminal.
# 1️⃣ Create the repo folder mkdir my-claude-agent && cd my-claude-agent # 2️⃣ Initialise a git repo (optional but recommended) git init # 3️⃣ Enable Corepack and tell it to use pnpm@9 (the latest stable at writing) corepack enable corepack prepare pnpm@9 --activate # 4️⃣ Create a root package.json – this holds shared devDependencies cat > package.json <<'EOF' { "name": "my-claude-agent", "private": true, "version": "0.1.0", "scripts": { "build": "pnpm -r run build" }, "devDependencies": { "typescript": "^5.5.0", "esbuild": "^0.21.0" } } EOF # 5️⃣ Tell pnpm which folders belong to the workspace cat > pnpm-workspace.yaml <<'EOF' packages: - "packages/*" EOF # 6️⃣ Scaffold the two packages mkdir -p packages/agent/src packages/shared/src # 7️⃣ Add a package.json for the Lambda handler (agent) cat > packages/agent/package.json <<'EOF' { "name": "agent", "version": "0.1.0", "main": "dist/handler.js", "type": "module", "scripts": { "build": "esbuild src/handler.ts --bundle --platform=node --target=node22 --outfile=dist/handler.js" }, "dependencies": { "@aws-sdk/client-bedrock-runtime": "^3.600.0", "shared": "workspace:*" } } EOF # 8️⃣ Add a package.json for shared utilities cat > packages/shared/package.json <<'EOF' { "name": "shared", "version": "0.1.0", "main": "dist/helpers.js", "type": "module", "scripts": { "build": "esbuild src/helpers.ts --bundle --platform=node --target=node22 --outfile=dist/helpers.js" }, "dependencies": {} } EOF # 9️⃣ Install everything – pnpm will create a single lockfile and a .pnpm folder pnpm install A few things to notice:
-
workspace:*in theagentpackage tells pnpm to link the localsharedpackage instead of pulling it from a registry. - The root
package.jsonis marked"private": trueso npm won’t try to publish the whole monorepo.
Tip: Running
pnpm -r run build(the-rflag means “recursive”) builds both packages in the correct order becauseagentdepends onshared.
Building and Deploying the Lambda: Code Walkthrough
1️⃣ The Lambda handler (agent/src/handler.ts)
// packages/agent/src/handler.ts import { BedrockRuntimeClient, InvokeModelCommand } from '@aws-sdk/client-bedrock-runtime'; import { formatPrompt } from 'shared'; // ← imported from the shared package // Create a Bedrock client – the SDK reads credentials from the Lambda execution role const bedrock = new BedrockRuntimeClient({ region: 'us-east-1' }); export const handler = async (event: any) => { // Build a prompt that asks Claude to generate a TypeScript function const prompt = formatPrompt(event.codeSnippet); // Prepare the Bedrock request – see the Bedrock docs for the exact JSON shape const command = new InvokeModelCommand({ modelId: 'anthropic.claude-3-5-sonnet-20240620-v1:0', contentType: 'application/json', accept: 'application/json', body: JSON.stringify({ prompt, temperature: 0.2, maxTokens: 1024 }) }); // Call Claude and wait for the response const response = await bedrock.send(command); const generated = JSON.parse(Buffer.from(response.body as Uint8Array).toString()); // Return a streaming‑compatible HTTP response (Lambda@Edge needs <1 MB, so keep it small) return { statusCode: 200, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ suggestion: generated }) }; }; Every line has a comment explaining its purpose.
2️⃣ Shared helper (shared/src/helpers.ts)
// packages/shared/src/helpers.ts /** * formatPrompt – simple utility that wraps user code in a Claude‑friendly instruction. * * @param snippet – raw code the user wants to improve. * @returns a string that Claude can understand. */ export const formatPrompt = (snippet: string): string => { return ` You are an AI coding assistant. Refactor the following TypeScript snippet to be more idiomatic, add type safety, and include comments. \`\`\`ts ${snippet} \`\`\` Provide ONLY the updated code block.`; }; 3️⃣ Bundling with esbuild while preserving pnpm’s layout
Because pnpm stores the real files in .pnpm, a naïve zip of the dist/ folder would miss native binaries and cause “module not found” errors at runtime. The fix is to copy the .pnpm directory into the deployment bundle after building, or run pnpm install --prod inside the bundle directory. Below is a small Node script that does both.
// build-and-package.ts – run with `node build-and-package.ts` import { execSync } from 'child_process'; import { copyFileSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync } from 'fs'; import { join, resolve } from 'path'; // 1️⃣ Build all workspace packages execSync('pnpm -r run build', { stdio: 'inherit' }); // 2️⃣ Create a temporary folder that will become the zip payload const outDir = resolve('deployment'); mkdirSync(outDir, { recursive: true }); // 3️⃣ Copy the compiled Lambda handler copyFileSync('packages/agent/dist/handler.js', join(outDir, 'handler.js')); // 4️⃣ Copy the .pnpm folder (the deduped store) – this is the crucial step execSync('cp -r .pnpm deployment/.pnpm', { stdio: 'inherit' }); // 5️⃣ Copy a minimal package.json that tells Lambda what to run const pkg = { type: 'module', main: 'handler.js', dependencies: { // Only keep production dependencies that the handler uses '@aws-sdk/client-bedrock-runtime': readFileSync('packages/agent/package.json', 'utf8') .match(/"@aws-sdk\/client-bedrock-runtime"\s*:\s*"([^"]+)"/)[1], // Shared utilities are already linked via the .pnpm store shared: readFileSync('packages/shared/package.json', 'utf8') .match(/"shared"\s*:\s*"([^"]+)"/)[1] } }; writeFileSync(join(outDir, 'package.json'), JSON.stringify(pkg, null, 2)); // 6️⃣ Finally, zip the folder (Linux/macOS command; Windows users can use 7‑zip) execSync('cd deployment && zip -r ../lambda.zip .', { stdio: 'inherit' }); console.log('✅ Deployment bundle ready: lambda.zip'); Why this works:
- The
esbuildbundles only your code, leaving external dependencies asrequirecalls. - By copying the
.pnpmfolder, the Lambda runtime can resolve those external modules exactly as pnpm laid them out. - The minimal
package.jsonensures Lambda’s Node runtime knows the entry point (handler.js) and doesn’t try to reinstall anything.
In plain English: Think of the
.pnpmfolder as the pantry where all your ingredients (libraries) are stored. If you move only the recipe (your code) without the pantry, the chef (Lambda) can’t find the spices and the dish fails.
4️⃣ Deploying with AWS CLI
# Replace <role-arn> with the ARN of a role that can call Bedrock aws lambda create-function \ --function-name claudeAgent \ --runtime nodejs22.x \ --handler handler.handler \ --zip-file fileb://lambda.zip \ --role arn:aws:iam::<account-id>:role/<role-arn> \ --memory-size 1024 \ --timeout 30 If you need to update an existing function, use aws lambda update-function-code instead of create-function.
Tip: Set
--memory-sizeto at least 1024 MiB for Claude calls; the Bedrock SDK benefits from more memory during JSON parsing.
Testing, Debugging, and Observability Tips
Local testing with the SAM CLI
The AWS Serverless Application Model (SAM) lets you run Lambda functions locally, emulating the same environment variables and IAM permissions (when you run sam local invoke with --profile).
sam local invoke ClaudeAgent --event event.json --region us-east-1 event.json might look like:
{ "codeSnippet": "function add(a,b){return a+b;}" } Debugging native‑module failures
If you forget to copy .pnpm (or run pnpm install --prod inside the bundle), you’ll see an error such as:
Error: Cannot find module '@aws-sdk/client-bedrock-runtime' The fix is to ensure the .pnpm folder is present in the zip, as shown in the build script.
Observability – CloudWatch Logs & Metrics
Add structured logs so you can filter on request IDs:
console.log(JSON.stringify({ level: 'info', requestId: context.awsRequestId, promptLength: prompt.length, responseTokens: generated?.usage?.outputTokens ?? 0 })); You can also publish custom CloudWatch metrics (e.g., ClaudeLatency) using the aws-sdk/client-cloudwatch client, then set alarms for cold‑start spikes.
Key takeaway: Monitoring the latency of the Bedrock call separately from the Lambda cold start helps you know whether the size reduction actually improved end‑to‑end response time.
Known gotchas you might run into
| Context | Gotcha | Why it matters | Quick fix |
|---|---|---|---|
| Lambda runtime | require(esm) in Node 22 silently fails when a layer provides an ESM‑only module | Node 22 enforces stricter ESM handling; a missing .mjs extension throws at import time | Stick to import syntax or add "type":"module" and rename files to .mjs |
| SnapStart + VPC | SnapStart speeds up the runtime but not the VPC attachment, so you see no improvement | The cold start cost is dominated by ENI (Elastic Network Interface) provisioning | Use VPC‑less Bedrock calls when possible, or pre‑warm ENIs with a small “ping” Lambda |
| Response streaming | Forgetting Content-Type: application/json makes API Gateway buffer the whole payload | Streaming works only if the downstream client knows how to parse chunks | Always set Content-Type and use isBase64Encoded: false |
| Provisioned Concurrency | Leaving it on for idle functions costs money | You pay for reserved concurrency even when idle | Use auto‑scaling or only enable for high‑traffic endpoints |
| Lambda@Edge | 1 MB max response size, while regular Lambda allows 6 MiB | Claude can emit large code suggestions; they’ll be truncated at the edge | Keep Claude’s output under 1 MB or route through a regular Lambda + CloudFront origin |
The Takeaway
What you should remember after reading this guide:
- Deployment size directly influences cold‑start latency. Smaller zip files download faster, making Claude‑driven agents feel snappier.
- pnpm workspaces deduplicate dependencies by storing a single copy in
.pnpmand linking it into each package, drastically reducing bundle size. - Corepack guarantees the same pnpm version for every developer and CI runner, avoiding “works on my machine” surprises.
- The
.pnpmfolder must travel with your Lambda zip. If you omit it, native or compiled modules cannot be resolved, causing runtime crashes. - A tiny build script that copies
.pnpmand a minimalpackage.jsoncreates a production‑ready bundle without extra layers or post‑install steps. - Observability matters. Log prompt length, Claude latency, and cold‑start times separately so you can prove the size reduction actually improves user experience.
By switching from npm/yarn to pnpm workspaces and handling the .pnpm folder correctly, you can cut deployment size by 40‑60 % and shave seconds off cold starts—turning a lumbering Claude assistant into a responsive, cost‑effective serverless service. Happy coding!
Transparency notice
This article was written with the help of an AI system — Groq (GPT OSS 120B).
Published: 2026-11-03 · Primary focus: PackageEcosystem
All code blocks are intended to be correct and runnable, but please verify them
against the official docs for the tools mentioned before using in production.Find an error? Drop a comment — corrections are always welcome.



