DEV Community

Dinesh_gowtham
Dinesh_gowtham

Posted on

Claude Function Calling with Lambda Function URLs: Building a Secure, Zero‑Config AI Endpoint

When you need a fast LLM endpoint, the first instinct is to spin up API Gateway—but Lambda Function URLs let you skip that extra hop entirely. Pair them with Claude’s function‑calling feature and you get a self‑contained AI service that’s ready in minutes. Yet most engineers still cling to the old, noisy stack.

Why Lambda Function URLs Beat API Gateway for LLM Endpoints

The problem with the “classic” stack

Imagine you’re sending a postcard. Using API Gateway is like handing the postcard to a postal clerk who stamps it, checks the address, and then hands it to the carrier. It works, but it adds a tiny delay and another place where something can go wrong.

Lambda Function URLs are the front‑door key: the postcard goes straight from your hand to the mailbox (the Lambda). There’s no middle‑man to configure, no extra cost for a separate service, and the latency is a few milliseconds lower.

Why that matters for large language models (LLMs)

  • Low latency – LLM calls already take 150‑300 ms; shaving even 20 ms off the surrounding HTTP hop improves the end‑user feel.
  • Zero‑config – No separate stage, stage variables, or deployment packages to keep in sync.
  • Built‑in HTTPS – Function URLs automatically expose a TLS‑secured endpoint, so you don’t have to attach a custom domain just to get encryption.

In plain English: A Lambda Function URL is the quickest, simplest way to expose a Lambda over HTTPS. If you only need one function (your Claude proxy) you can skip API Gateway entirely.

Key takeaway: For a single‑function AI service, Function URLs give you fewer moving parts, lower cost, and a clearer mental model.

Setting Up Claude Function Calling in a Node.js 22 Lambda

The “why” of function calling

Claude’s function‑calling feature lets the model suggest a structured action (a tool call) instead of returning free‑form text. Think of it like a customer asking a clerk for a receipt; the clerk hands back a neatly formatted paper rather than a scribbled note. This structure makes it safe to execute code—your Lambda can trust the JSON shape and act on it.

Minimal Lambda skeleton

Below is a complete Lambda handler written for Node.js 22 (the current LTS version). It:

  1. Parses the incoming HTTP POST body (the payment request).
  2. Sends a fetch request to Claude’s /v1/chat/completions endpoint, describing a single tool called validatePayment.
  3. Reads Claude’s JSON‑encoded tool call, validates it, and returns a clean response.
  4. Persists both the incoming request and Claude’s raw reply to an S3 bucket for audit.
// file: src/index.ts
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";

// The S3 bucket where we store audit logs.
// Replace with your bucket name (must already exist).
const AUDIT_BUCKET = process.env.AUDIT_BUCKET ?? "my-ai-audit-bucket";

// Create a single S3 client – it reuses HTTP connections automatically.
const s3 = new S3Client({});

/**
 * Lambda entry point. The runtime passes an `event` object that contains
 * the raw HTTP request when the function is invoked via a Function URL.
 */
export const handler = async (event: any) => {
  try {
    // ---------- 1️⃣ Parse the incoming payment request ----------
    // `event.body` is a JSON string because Function URLs forward the raw body.
    const requestPayload = JSON.parse(event.body);
    // Example shape we expect:
    // { "orderId": "12345", "amountCents": 1999, "currency": "USD", "cardToken": "tok_abc" }

    // ---------- 2️⃣ Call Claude with a tool definition ----------
    const claudeResponse = await callClaude(requestPayload);

    // ---------- 3️⃣ Extract the tool call (validatePayment) ----------
    const toolResult = extractToolResult(claudeResponse);

    // ---------- 4️⃣ Persist request + response for audit ----------
    await persistAudit(event.body, JSON.stringify(claudeResponse));

    // ---------- 5️⃣ Return the tool result to the caller ----------
    return {
      statusCode: 200,
      headers: {
        "Content-Type": "application/json",
        // Simple CORS header – adjust the origin as needed.
        "Access-Control-Allow-Origin": "*",
      },
      body: JSON.stringify(toolResult),
    };
  } catch (err: any) {
    // ---------- Error handling ----------
    const status = err.statusCode ?? 500;
    return {
      statusCode: status,
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ error: err.message ?? "Internal error" }),
    };
  }
};

/**
 * Calls Claude’s chat completion endpoint with a single tool called
 * `validatePayment`. The tool tells Claude how to format a call that we
 * can safely execute.
 */
async function callClaude(paymentPayload: any) {
  // Claude API key should be stored in Secrets Manager / Parameter Store.
  const CLAUDE_API_KEY = process.env.CLAUDE_API_KEY!;
  const CLAUDE_URL = "https://api.anthropic.com/v1/chat/completions";

  // The tool definition – this tells Claude the JSON schema it must return.
  const tools = [
    {
      name: "validatePayment",
      description: "Check a credit‑card payment request for validity",
      input_schema: {
        type: "object",
        properties: {
          orderId: { type: "string" },
          amountCents: { type: "integer" },
          currency: { type: "string" },
          cardToken: { type: "string" },
        },
        required: ["orderId", "amountCents", "currency", "cardToken"],
      },
    },
  ];

  // Build the request payload for Claude.
  const body = {
    model: "claude-3-5-sonnet-20240620",
    max_tokens: 1024,
    messages: [
      { role: "user", content: "Validate this payment request." },
      { role: "assistant", tool_calls: [] }, // placeholder for tool call
    ],
    tools, // pass our tool definition
    // We also give Claude the raw payment data so it can decide whether to call the tool.
    // (Claude can also ask follow‑up questions; we keep it simple here.)
    // In a real app you might embed the payload in a system message.
    // For demo purposes we send it as part of the user message.
    // Example:
    // "Here is the payload: { ... }"
  };

  // Add the actual payload to the user content.
  (body.messages[0] as any).content = `Payment payload: ${JSON.stringify(
    paymentPayload,
  )}`;

  const response = await fetch(CLAUDE_URL, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "x-api-key": CLAUDE_API_KEY,
      // Claude expects an explicit version header.
      "anthropic-version": "2023-06-01",
    },
    body: JSON.stringify(body),
  });

  if (!response.ok) {
    // Propagate HTTP errors as JavaScript exceptions.
    const errText = await response.text();
    const error = new Error(`Claude API error ${response.status}: ${errText}`);
    (error as any).statusCode = response.status;
    throw error;
  }

  // Claude returns a JSON structure that includes `tool_calls` when it wants to invoke a function.
  return await response.json();
}

/**
 * Pulls the JSON payload out of Claude’s tool call response.
 * Throws a ValidationError if the schema is not respected.
 */
function extractToolResult(claudeResponse: any) {
  // The path to tool calls can differ by model version; here we use the standard shape.
  const toolCalls = claudeResponse?.choices?.[0]?.message?.tool_calls;
  if (!toolCalls || toolCalls.length === 0) {
    throw new Error("Claude did not return a tool call");
  }

  const call = toolCalls[0];
  if (call.name !== "validatePayment") {
    throw new Error(`Unexpected tool name: ${call.name}`);
  }

  // `function_arguments` is a JSON string – parse it.
  let args: any;
  try {
    args = JSON.parse(call.function_arguments);
  } catch {
    throw new Error("Failed to parse Claude's tool arguments");
  }

  // Very light validation – in production you would use a JSON schema validator.
  const required = ["orderId", "amountCents", "currency", "cardToken"];
  for (const key of required) {
    if (!(key in args)) {
      throw new Error(`Missing required field ${key}`);
    }
  }

  // Return the clean, validated object.
  return { validated: true, details: args };
}

/**
 * Writes both the raw inbound request and Claude’s raw reply to S3.
 * Using a timestamped key makes it easy to browse audit logs later.
 */
async function persistAudit(rawRequest: string, rawResponse: string) {
  const timestamp = new Date().toISOString();
  const requestKey = `audit/${timestamp}_request.json`;
  const responseKey = `audit/${timestamp}_claude.json`;

  // Put the request object.
  await s3.send(
    new PutObjectCommand({
      Bucket: AUDIT_BUCKET,
      Key: requestKey,
      Body: rawRequest,
      ContentType: "application/json",
    }),
  );

  // Put the Claude response object.
  await s3.send(
    new PutObjectCommand({
      Bucket: AUDIT_BUCKET,
      Key: responseKey,
      Body: rawResponse,
      ContentType: "application/json",
    }),
  );
}
Enter fullscreen mode Exit fullscreen mode

Explanation of the most important lines

Line What it does
event.body The HTTP payload sent by the caller (the payment request).
fetch(CLAUDE_URL, …) Calls Claude over HTTPS using the built‑in fetch API (no extra library needed).
tools Describes a tool – a function that Claude can ask you to run. The schema tells Claude exactly what keys and types to send back.
tool_calls The place in Claude’s response where the model tells you “I want to run validatePayment with these arguments”.
PutObjectCommand An AWS SDK command that stores an object (a file) in an S3 bucket.
process.env.CLAUDE_API_KEY Pulls the secret API key from the Lambda’s environment variables – never hard‑code secrets.

Tip: When you first test locally, set process.env.CLAUDE_API_KEY in a .env file and use the dotenv package. In production you should store the key in AWS Secrets Manager and grant the Lambda read access via an IAM role.

Gotcha #1 – Payload size limit

Lambda Function URLs default to a 6 KB request body limit. Claude’s tool‑call JSON can easily exceed that when you include rich data (e.g., a whole order object). If you forget to raise MaximumPayloadSize (via the console or aws lambda update-function-url-config), the runtime silently truncates the body and you’ll see mysterious “invalid JSON” errors.

aws lambda update-function-url-config \
  --function-name MyClaudeProxy \
  --auth-type NONE \
  --max-payload-size 64KB
Enter fullscreen mode Exit fullscreen mode

In plain English: Think of the default limit as a tiny mailbox slot; you need to ask AWS for a bigger slot before you start dropping larger letters in.


Securely Exposing the Endpoint with IAM Auth and CORS

Why security matters even for a “private” AI helper

An LLM can be instructed to generate code or manipulate data. If anyone on the internet can hit your endpoint, they could flood Claude with malicious prompts, rack up usage charges, or even exfiltrate data from your S3 bucket. IAM authentication gives you a strong, AWS‑native gatekeeper without adding a separate auth layer.

Enabling IAM auth on the Function URL

When you create the Function URL, set --auth-type AWS_IAM. The runtime will then require a signed request (SigV4). A typical front‑end can obtain temporary credentials from Amazon Cognito or from an EC2 instance role.

aws lambda create-function-url-config \
  --function-name MyClaudeProxy \
  --auth-type AWS_IAM \
  --cors '{"AllowOrigins":["https://myapp.example.com"],"AllowMethods":["POST"],"AllowHeaders":["Authorization","Content-Type"]}'
Enter fullscreen mode Exit fullscreen mode
  • CORS – Cross‑Origin Resource Sharing, a browser security feature. The JSON above tells browsers that only https://myapp.example.com may call the endpoint, and only POST with the listed headers are allowed.

Minimal IAM policy for a caller

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "lambda:InvokeFunctionUrl",
      "Resource": "arn:aws:lambda:us-east-1:123456789012:function:MyClaudeProxy"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Attach this policy to the role that your front‑end (or another Lambda) assumes.

Key takeaway: IAM auth + a tight CORS rule gives you a “door with a lock and a peephole” – only callers with proper AWS credentials can open it, and browsers can’t be tricked into sending data from another site.

Known gotcha in the Node 22 runtime

Node 22 introduced native ES modules (.mjs) by default. If you still use require('esm') to force CommonJS, the Lambda layer that bundles @aws-sdk/client-lambda silently fails to load, producing a cryptic “Cannot find module” error at cold start. The fix is to switch the file extension to .js and add "type": "module" in package.json, or keep everything CommonJS by naming the file .cjs.


Persisting Calls and Auditing with S3 Object Lambda

Why you need an audit trail

Financial operations (like payment validation) are often subject to compliance rules. Keeping a tamper‑evident log of both the caller’s request and Claude’s exact reply helps you answer “who did what, when”.

Using S3 Object Lambda for on‑the‑fly redaction

Sometimes you must redact sensitive fields (e.g., cardToken) before the log is stored long‑term. S3 Object Lambda lets you attach a small Lambda that transforms the object as it’s being written.

aws s3control create-access-point-for-object-lambda \
  --name audit-redact-ap \
  --region us-east-1 \
  --configuration '{
    "SupportingAccessPoint": "arn:aws:s3:us-east-1:123456789012:accesspoint/my-audit-bucket-ap",
    "TransformationConfigurations": [{
      "Actions": ["GetObject", "PutObject"],
      "ContentTransformation": {
        "AwsLambda": {
          "FunctionArn": "arn:aws:lambda:us-east-1:123456789012:function:RedactCardToken"
        }
      }
    }]
  }'
Enter fullscreen mode Exit fullscreen mode

Your RedactCardToken Lambda receives the raw object, removes cardToken, and returns the sanitized version to S3.

Analogy: Think of an S3 Object Lambda as a security guard at a mailroom who opens every envelope, removes any classified documents, and then reseals it before it goes into storage.

Simple redaction Lambda (Node 22)

// file: redact.ts
export const handler = async (event: any) => {
  const original = JSON.parse(event.getObjectContext.inputS3Url);
  // Delete the sensitive field
  delete original.cardToken;
  return {
    statusCode: 200,
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(original),
  };
};
Enter fullscreen mode Exit fullscreen mode

Tip: Because the redaction Lambda runs for every put, keep it tiny (no external SDKs) to avoid extra latency.


Testing, Debugging, and the One Gotcha You’ll Hit

Local testing with SAM CLI

The Serverless Application Model (SAM) CLI can invoke a Function URL locally, letting you see the full request/response cycle without deploying.

sam local invoke MyClaudeProxy \
  -e events/payment-request.json \
  --env-vars env.json
Enter fullscreen mode Exit fullscreen mode

events/payment-request.json might contain:

{
  "body": "{\"orderId\":\"A100\",\"amountCents\":2500,\"currency\":\"USD\",\"cardToken\":\"tok_123\"}"
}
Enter fullscreen mode Exit fullscreen mode

Debugging payload truncation

If Claude returns a tool_calls array but the Lambda logs Claude did not return a tool call, the most common cause is the 6 KB limit mentioned earlier. Check CloudWatch logs for a line like:

2026-08-28T12:34:56.789Z    ERROR   JSON Parse error: Unexpected end of JSON input
Enter fullscreen mode Exit fullscreen mode

That indicates the incoming body was cut off. Verify the Function URL’s MaximumPayloadSize setting.

Handling HTTP 4xx from Claude

Claude may respond with 400 Bad Request if the tool schema is malformed. Your callClaude helper already throws an error that bubbles up to the top‑level catch. To surface a friendlier message to the caller:

if (response.status >= 400 && response.status < 500) {
  const errMsg = await response.text();
  const err = new Error(`Invalid request: ${errMsg}`);
  (err as any).statusCode = response.status;
  throw err;
}
Enter fullscreen mode Exit fullscreen mode

Key takeaway: Centralizing error handling lets you map Claude’s HTTP errors to your own API’s error model, keeping the front‑end experience consistent.

Gotcha #2 – Lambda response streaming

If you later decide to stream Claude’s response (useful for very large payloads), you must set Content-Type: application/octet-stream and add Transfer-Encoding: chunked headers. Omitting those causes the runtime to buffer the entire response, which defeats the purpose of streaming and can hit the 6 KB limit again.


The Takeaway

What you now have in your toolbox

  • Function URLs are the fast, zero‑config way to expose a single Lambda as an HTTPS endpoint. No API Gateway, no extra costs.
  • Claude’s function‑calling tool gives you a structured JSON contract, making it safe to execute model‑suggested actions.
  • IAM authentication + CORS provide a strong, AWS‑native security perimeter without writing custom auth code.
  • S3 Object Lambda lets you redact or transform audit logs on the fly, keeping compliance simple.
  • Payload‑size limit (6 KB) is the hidden roadblock most developers miss; raise MaximumPayloadSize early in the setup.
  • Error handling and streaming require explicit headers; otherwise the runtime falls back to buffering and you’ll hit hidden limits.

By stitching these pieces together, you can spin up a production‑grade, self‑contained AI endpoint in under ten minutes—perfect for payment validation, order verification, or any other task where you want the model’s reasoning plus the safety of a typed contract. Happy coding!


Transparency notice

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

Published: 2026-08-28 · 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)