DEV Community

Dinesh_gowtham
Dinesh_gowtham

Posted on

AI Pair Programming with Lambda: Build a Claude‑Powered Coding Assistant and Enable Prompt Caching

Developers are tired of line‑by‑line autocomplete and want an AI that can take a whole function and return production‑ready code. By using AWS Lambda and Claude’s API you can offload that work to the cloud and get instant, cached responses. This guide shows exactly how.

Why AI Pair Programming Needs More Than Autocomplete

Autocomplete is useful for filling in a variable name, but a real pair programmer can understand the intent of an entire block, suggest a new implementation, and even refactor code to match your style guide.

In plain English: Think of autocomplete as a spell‑checker that fixes single words, while a coding assistant is like a seasoned teammate who can rewrite a whole paragraph for you.

The missing piece: stateful prompting

Large language models (LLMs) like Claude work best when you give them a prompt – a text description of what you want. If you rebuild that prompt for every request, you waste time and money. Keeping the prompt (or parts of it) in a local file that survives between Lambda invocations is called prompt caching.

Key takeaway: Prompt caching turns a cold‑start‑only request into a warm‑request that reuses work already done.

Code: Minimal Lambda that just echoes a prompt (no caching yet)

import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';

// Simple handler that returns the received body unchanged
export const handler = async (
  event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
  // Parse the incoming JSON payload
  const body = JSON.parse(event.body ?? '{}');

  // Echo back the code snippet the user sent
  return {
    statusCode: 200,
    body: JSON.stringify({ received: body.code }),
  };
};
Enter fullscreen mode Exit fullscreen mode

What you see:

  • APIGatewayProxyEvent – the shape of the HTTP request Lambda receives from API Gateway.
  • APIGatewayProxyResult – the shape of the HTTP response Lambda must return.

The example works, but it does nothing with Claude and it rebuilds the prompt every time. The next sections add security, caching, and the real LLM call.


Setting Up a Secure Claude API Call in Lambda

Claude’s API requires an API key. Storing that key directly in code would expose it to anyone who can read the source. AWS Secrets Manager is a managed service that encrypts secrets at rest and provides fine‑grained access control.

Why use Secrets Manager?

  • Keeps the key out of source control.
  • Allows rotation without code changes.
  • Provides audit logs for every access.

Gotcha: Secrets Manager charges per secret and per retrieval

A secret costs $0.40 per month, and each GetSecretValue call costs a fraction of a cent. If your Lambda fetches the secret on every request, the bill can grow unexpectedly. We’ll cache the secret in the same warm‑container storage (/tmp) after the first fetch.

Code: Securely read the Claude key and call the API

import {
  SecretsManagerClient,
  GetSecretValueCommand,
} from '@aws-sdk/client-secrets-manager';
import fetch from 'node-fetch'; // Node 22 has native fetch; keep for clarity

// Name of the secret that holds the Claude API key
const SECRET_NAME = process.env.CLAUDE_API_SECRET_NAME!;

// Reuse the client across invocations (saves connection overhead)
const secretsClient = new SecretsManagerClient({});

// In‑memory cache for the key; survives warm container restarts
let cachedApiKey: string | undefined;

/**
 * Retrieve the Claude API key.
 * On a warm container we read from `cachedApiKey`.
 * On a cold start we fetch from Secrets Manager and store it.
 */
async function getClaudeApiKey(): Promise<string> {
  if (cachedApiKey) {
    // Warm path – no extra cost
    return cachedApiKey;
  }

  // Cold path – fetch once, then cache
  const command = new GetSecretValueCommand({ SecretId: SECRET_NAME });
  const response = await secretsClient.send(command);

  if (!response.SecretString) {
    throw new Error('Secret retrieved but empty');
  }

  cachedApiKey = response.SecretString; // Save for later calls
  return cachedApiKey;
}

/**
 * Call Claude with a ready‑made prompt.
 * The function returns Claude’s `completion` field.
 */
export async function callClaude(prompt: string): Promise<string> {
  const apiKey = await getClaudeApiKey();

  const response = await fetch('https://api.anthropic.com/v1/complete', {
    method: 'POST',
    headers: {
      // Authorization header tells Claude who you are
      'x-api-key': apiKey,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      // `prompt` is the text we want Claude to continue
      prompt,
      max_tokens_to_sample: 1024,
      model: 'claude-3-5-sonnet-20240620',
    }),
  });

  if (!response.ok) {
    const err = await response.text();
    throw new Error(`Claude API error: ${response.status} ${err}`);
  }

  const data = await response.json();
  return data.completion as string;
}
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • SecretsManagerClient from @aws-sdk/client-secrets-manager talks to the Secrets service.
  • cachedApiKey lives in the container’s memory, so after the first request the Lambda does not hit Secrets Manager again.
  • callClaude sends a JSON payload to Claude’s endpoint and returns the generated text.

Tip: If you see unexpected charges, check CloudWatch logs for how often GetSecretValueCommand runs. It should only happen once per warm container.


Implementing Prompt Caching with Native TypeScript Stripping

Claude works best when you give it a system prompt that explains its role (e.g., “You are a helpful pair programmer that writes production‑ready TypeScript”). Building that prompt from scratch for each request adds latency and cost.

Why store the prompt on disk?

A Lambda container has a writable /tmp directory that persists for the lifetime of the container (up to 10 GB). By writing a JSON file there we keep the prompt alive across many invocations, even if the function is recycled later.

Analogy: Warm kitchen vs. cold pantry

Imagine a kitchen that keeps a pot of sauce simmering (the prompt) ready for any dish you want to prepare. When the kitchen is “warm,” you just ladle sauce into the pan. If the kitchen is “cold,” you have to start the sauce from scratch—slow and wasteful. /tmp is that simmering pot.

Gotcha: Cold starts wipe /tmp

When Lambda scales out, a new container starts with an empty /tmp. The first request after a scale‑out must rebuild the prompt file, which will be slower. We’ll log that event so you can monitor it.

Code: Prompt cache helper

import { promises as fs } from 'fs';
import path from 'path';

// Location of the cache file inside the writable /tmp directory
const CACHE_PATH = '/tmp/promptCache.json';

// Base system prompt that tells Claude what to do
const BASE_PROMPT = `
You are a pair programmer that writes production‑ready TypeScript.
When given a code snippet, return a refactored version that follows
the Airbnb style guide and includes type annotations.
Only return the code, no explanations.
`.trim();

/**
 * Load the cached prompt if it exists, otherwise create it.
 * Returns the full prompt string that will be sent to Claude.
 */
export async function getPromptCache(): Promise<string> {
  try {
    // Try reading the existing cache file
    const raw = await fs.readFile(CACHE_PATH, 'utf-8');
    const cached = JSON.parse(raw);
    // Simple version check – you could add more sophisticated diffing
    if (cached.version === 1) {
      console.log('✅ Prompt cache hit');
      return cached.prompt;
    }
  } catch (e) {
    // File does not exist or is malformed – we need to rebuild
    console.log('⚡ Prompt cache miss – rebuilding');
  }

  // Build the prompt once and write it to /tmp for future calls
  const fullPrompt = BASE_PROMPT + '\n\nUser code:';
  const payload = {
    version: 1,
    prompt: fullPrompt,
  };
  await fs.writeFile(CACHE_PATH, JSON.stringify(payload), 'utf-8');
  console.log('🗂 Prompt cache written to /tmp');
  return fullPrompt;
}
Enter fullscreen mode Exit fullscreen mode

What the code does:

  • Attempts to read /tmp/promptCache.json.
  • If the file exists and matches the expected version, we reuse it.
  • If not, we construct the prompt and write it to the file.
  • Logging statements make it clear when a cold start occurs.

Key takeaway: Prompt caching turns a potentially expensive rebuild into a cheap file read, dramatically reducing latency for warm containers.


Connecting the Lambda via API Gateway for Real‑Time Editing

API Gateway is the HTTP front‑door that lets browsers or IDE extensions call your Lambda. To make the interaction feel like a live editor, we need to return the response quickly and with the right headers.

Why streaming matters

If Claude returns a large block of code, buffering the whole response in Lambda before sending it can add extra seconds. API Gateway supports response streaming, but only when you set Content-Type: application/json and use the isBase64Encoded flag correctly.

Gotcha: Response streaming needs explicit headers

If you omit Content-Type: application/json, API Gateway will buffer the whole payload, increasing latency. Also, Lambda@Edge has a 1 MB response limit, but a regular Lambda can stream larger payloads.

Code: Full Lambda handler with caching and streaming

import {
  APIGatewayProxyEvent,
  APIGatewayProxyResult,
  Context,
} from 'aws-lambda';
import { getPromptCache } from './promptCache';
import { callClaude } from './claudeClient';

// Helper to combine system prompt with user snippet
function buildFullPrompt(systemPrompt: string, userCode: string): string {
  // Insert the user code after the placeholder we defined earlier
  return `${systemPrompt}\n\n${userCode}`;
}

/**
 * Main Lambda entry point.
 * Receives a JSON body: { "code": "<typescript snippet>" }
 * Returns: { "refactored": "<Claude's output>" }
 */
export const handler = async (
  event: APIGatewayProxyEvent,
  _context: Context
): Promise<APIGatewayProxyResult> => {
  try {
    // 1️⃣ Parse incoming request
    const { code } = JSON.parse(event.body ?? '{}');
    if (!code) {
      return {
        statusCode: 400,
        body: JSON.stringify({ error: 'Missing `code` field' }),
      };
    }

    // 2️⃣ Load (or rebuild) the cached system prompt
    const systemPrompt = await getPromptCache();

    // 3️⃣ Build the final prompt that Claude will see
    const fullPrompt = buildFullPrompt(systemPrompt, code);

    // 4️⃣ Call Claude – this may take a second or two
    const claudeResult = await callClaude(fullPrompt);

    // 5️⃣ Return the result with streaming‑compatible headers
    return {
      statusCode: 200,
      headers: {
        // Explicitly tell API Gateway this is JSON so it can stream
        'Content-Type': 'application/json',
        // CORS headers if you call from a browser
        'Access-Control-Allow-Origin': '*',
      },
      // No need for base64 encoding – the payload is plain text JSON
      isBase64Encoded: false,
      body: JSON.stringify({ refactored: claudeResult.trim() }),
    };
  } catch (err) {
    console.error('❌ Handler error', err);
    return {
      statusCode: 500,
      body: JSON.stringify({ error: (err as Error).message }),
    };
  }
};
Enter fullscreen mode Exit fullscreen mode

Step‑by‑step explanation:

  1. Parse request – we expect a JSON body with a code field.
  2. Load prompt cache – getPromptCache either reads the file or rebuilds it.
  3. Combine system prompt and user snippet.
  4. Call Claude – the heavy lifting happens here.
  5. Return a JSON response with the correct Content-Type so API Gateway can stream.

Tip: Turn on Provisioned Concurrency if your team cannot tolerate any cold starts, but remember it costs money even when idle. Watch the billing dashboard after enabling it.


Testing the Assistant Locally with Node.js Test Runner

Before you deploy, run the Lambda handler locally to catch syntax errors and verify the caching logic. Using a lightweight test runner like Vitest (or plain node with assert) keeps the setup simple.

Why local testing matters

  • Lambda’s environment differs from your workstation (e.g., /tmp size).
  • You can mock Secrets Manager to avoid accidental charges.
  • Fast feedback loop reduces the number of deploy‑to‑test cycles.

Gotcha: Mocking @aws-sdk/client-secrets-manager

If you forget to mock the Secrets call, the test will hit the real service and incur cost. Use the SDK’s middlewareStack to inject a fake response.

Code: Minimal test file (handler.test.ts)

import { handler } from './handler';
import { APIGatewayProxyEvent } from 'aws-lambda';
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';

// ---------- Mock Secrets Manager ----------
jest.mock('@aws-sdk/client-secrets-manager', () => {
  const original = jest.requireActual('@aws-sdk/client-secrets-manager');
  return {
    ...original,
    SecretsManagerClient: jest.fn(() => ({
      send: async (cmd: GetSecretValueCommand) => ({
        SecretString: 'FAKE_CLAUDE_API_KEY',
      }),
    })),
  };
});

// ---------- Mock fetch (Claude) ----------
global.fetch = jest.fn(() =>
  Promise.resolve({
    ok: true,
    json: () => Promise.resolve({ completion: 'refactored code' }),
  })
) as any;

// ---------- Helper to build a fake API Gateway event ----------
function makeEvent(body: unknown): APIGatewayProxyEvent {
  return {
    body: JSON.stringify(body),
    headers: {},
    multiValueHeaders: {},
    httpMethod: 'POST',
    isBase64Encoded: false,
    path: '/',
    pathParameters: null,
    queryStringParameters: null,
    multiValueQueryStringParameters: null,
    stageVariables: null,
    requestContext: {} as any,
    resource: '',
  };
}

// ---------- The test ----------
test('handler returns refactored code', async () => {
  const event = makeEvent({ code: 'function add(a,b){return a+b;}' });

  const result = await handler(event, {} as any);

  expect(result.statusCode).toBe(200);
  const payload = JSON.parse(result.body);
  expect(payload.refactored).toBe('refactored code');
});
Enter fullscreen mode Exit fullscreen mode

Explanation of the test:

  • Mock Secrets Manager so no real secret is fetched.
  • Mock fetch to simulate Claude’s response.
  • Build a minimal APIGatewayProxyEvent with a code snippet.
  • Call the handler and assert that we get a 200 response with the expected refactored field.

In plain English: This test pretends the Lambda is running in the cloud, but everything stays on your laptop, keeping you from accidental spend.

Run the test with npx vitest or npm test depending on your setup.


The Takeaway

Key points to remember

  • Autocomplete fixes tiny bits; a prompt‑driven LLM can rewrite whole functions, making a real pair‑programming experience.
  • Store secrets in AWS Secrets Manager and cache the API key in memory to avoid per‑request charges.
  • Write the system prompt to /tmp/promptCache.json; warm containers reuse it, dramatically cutting latency.
  • API Gateway needs explicit Content-Type: application/json for streaming; otherwise it buffers and slows you down.
  • Local tests with mocked Secrets Manager and fetch let you validate logic without incurring cloud costs.
  • Cold starts will always rebuild the prompt cache; log the miss so you can monitor scaling behavior.

Complete, Runnable Code Example

Below is the full set of files you need. Place them in a TypeScript project (npm init -y && npm i typescript @aws-sdk/client-secrets-manager @aws-sdk/client-lambda node-fetch) and run tsc to compile.

src/promptCache.ts

import { promises as fs } from 'fs';

const CACHE_PATH = '/tmp/promptCache.json';
const BASE_PROMPT = `
You are a pair programmer that writes production‑ready TypeScript.
When given a code snippet, return a refactored version that follows
the Airbnb style guide and includes type annotations.
Only return the code, no explanations.
`.trim();

export async function getPromptCache(): Promise<string> {
  try {
    const raw = await fs.readFile(CACHE_PATH, 'utf-8');
    const cached = JSON.parse(raw);
    if (cached.version === 1) {
      console.log('✅ Prompt cache hit');
      return cached.prompt;
    }
  } catch {
    console.log('⚡ Prompt cache miss – rebuilding');
  }

  const fullPrompt = BASE_PROMPT + '\n\nUser code:';
  const payload = { version: 1, prompt: fullPrompt };
  await fs.writeFile(CACHE_PATH, JSON.stringify(payload), 'utf-8');
  console.log('🗂 Prompt cache written');
  return fullPrompt;
}
Enter fullscreen mode Exit fullscreen mode

src/claudeClient.ts

import {
  SecretsManagerClient,
  GetSecretValueCommand,
} from '@aws-sdk/client-secrets-manager';
import fetch from 'node-fetch';

const SECRET_NAME = process.env.CLAUDE_API_SECRET_NAME!;
const secretsClient = new SecretsManagerClient({});

let cachedApiKey: string | undefined;

async function getClaudeApiKey(): Promise<string> {
  if (cachedApiKey) return cachedApiKey;
  const cmd = new GetSecretValueCommand({ SecretId: SECRET_NAME });
  const resp = await secretsClient.send(cmd);
  if (!resp.SecretString) throw new Error('Secret empty');
  cachedApiKey = resp.SecretString;
  return cachedApiKey;
}

export async function callClaude(prompt: string): Promise<string> {
  const apiKey = await getClaudeApiKey();
  const res = await fetch('https://api.anthropic.com/v1/complete', {
    method: 'POST',
    headers: {
      'x-api-key': apiKey,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      prompt,
      max_tokens_to_sample: 1024,
      model: 'claude-3-5-sonnet-20240620',
    }),
  });

  if (!res.ok) {
    const txt = await res.text();
    throw new Error(`Claude error ${res.status}: ${txt}`);
  }

  const data = await res.json();
  return data.completion as string;
}
Enter fullscreen mode Exit fullscreen mode

src/handler.ts

import {
  APIGatewayProxyEvent,
  APIGatewayProxyResult,
  Context,
} from 'aws-lambda';
import { getPromptCache } from './promptCache';
import { callClaude } from './claudeClient';

function buildFullPrompt(systemPrompt: string, userCode: string): string {
  return `${systemPrompt}\n\n${userCode}`;
}

export const handler = async (
  event: APIGatewayProxyEvent,
  _context: Context
): Promise<APIGatewayProxyResult> => {
  try {
    const { code } = JSON.parse(event.body ?? '{}');
    if (!code) {
      return {
        statusCode: 400,
        body: JSON.stringify({ error: 'Missing `code` field' }),
      };
    }

    const systemPrompt = await getPromptCache();
    const fullPrompt = buildFullPrompt(systemPrompt, code);
    const claudeResult = await callClaude(fullPrompt);

    return {
      statusCode: 200,
      headers: {
        'Content-Type': 'application/json',
        'Access-Control-Allow-Origin': '*',
      },
      isBase64Encoded: false,
      body: JSON.stringify({ refactored: claudeResult.trim() }),
    };
  } catch (e) {
    console.error('❌ Unexpected error', e);
    return {
      statusCode: 500,
      body: JSON.stringify({ error: (e as Error).message }),
    };
  }
};
Enter fullscreen mode Exit fullscreen mode

src/handler.test.ts (optional, see earlier)

// (Copy the test code from the “Testing the Assistant Locally” section)
Enter fullscreen mode Exit fullscreen mode

Deploy the handler to Lambda (Node.js 22.x runtime) and attach an API Gateway with POST integration. Set the environment variable CLAUDE_API_SECRET_NAME to the name of the secret you created in Secrets Manager.


Next Steps

  • Add versioning to the prompt cache – when you change the system prompt, bump the version number so containers rebuild automatically.
  • Enable Provisioned Concurrency for mission‑critical endpoints, but monitor the cost dashboard.
  • Instrument with CloudWatch Logs to track cold‑start frequency (Prompt cache miss) and set up alerts if it spikes.
  • Experiment with streaming responses: split Claude’s output into chunks and forward them as they arrive for an even snappier editor experience.

You now have a production‑grade, Claude‑powered coding assistant that runs in AWS Lambda, caches prompts to save time and money, and can be called


Transparency notice

This article was written with the help of an AI system — Groq (GPT OSS 120B).

Published: 2026-09-25 · Primary focus: Lambda

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.

Top comments (0)