{"slug": "claude-function-calling-on-bedrock-wiring-a-stateless-lambda-for-real-time-ai", "title": "Claude Function Calling on Bedrock: Wiring a Stateless Lambda for Real‑Time AI Actions", "summary": "A developer demonstrates how to wire Claude on Amazon Bedrock to a stateless Lambda for real-time AI actions, using function calling to trigger a DynamoDB update. The example shows a minimal TypeScript handler that parses an API Gateway event, invokes Claude with a tool definition, and handles the model's function call response. The approach keeps prompts short, makes systems extensible, and provides auditability by logging every function call.", "body_md": "Developers love the idea of LLMs that can call your code, but most examples drown in boilerplate or require heavyweight orchestration. In just a few lines you can let Claude on Bedrock trigger a Lambda, update a DynamoDB table, and respond instantly to an API call. This post shows exactly how.\n\n**Bedrock** is Amazon’s managed service that hosts large language models (LLMs) like Claude. *Function calling* (sometimes called tool use) is a feature where the model can suggest that your program run a specific piece of code – for example, “add a new user”. The model returns a JSON payload that describes the function name and arguments. Your service reads that payload, runs the real function, and then sends the result back to the model so it can continue the conversation.\n\nIn plain English:Think of the LLM as a helpful assistant that says, “Hey, could you add this person to the database?” and hands you a sticky note with the details. Your code reads the note, does the work, and tells the assistant “Done”.\n\nWhy does this matter? Instead of hard‑coding all possible business logic into prompts, you let the model decide *when* to invoke real code. This keeps prompts short, makes the system extensible, and gives you auditability – you can log every function call.\n\n| Piece | What it is | Why you need it |\n|---|---|---|\nTool definition |\nJSON that tells Bedrock what functions are available (name, description, parameter schema) | The model can only call functions it knows about |\nToolResult field |\nThe part of the response that contains the model’s chosen function call | Without it, you never see the request to run your code |\nStreaming vs non‑streaming |\n`stream=true` returns a series of Server‑Sent Events (SSE); `stream=false` returns a single JSON payload |\nStreaming lets you forward the answer to the caller as soon as it’s ready, but changes the shape of the result |\n\nTip:The same TypeScript interface won’t match both shapes. Write two narrow types or a discriminated union and let the compiler help you.\n\nA *Lambda* is a short‑lived function that runs in AWS without you managing servers. We’ll use Node.js 22 (the current LTS) and TypeScript for type safety.\n\nWhy start with a bare‑bones handler? It isolates the Bedrock call from any other infrastructure, making the example easy to copy‑paste into the AWS console or a CDK stack.\n\n``` js\n// src/lambda.ts\nimport { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';\nimport {\n  BedrockRuntimeClient,\n  InvokeModelWithResponseStreamCommand,\n  InvokeModelCommand,\n} from '@aws-sdk/client-bedrock-runtime';\nimport { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb';\n\n// Create a Bedrock client that talks to the Claude model in the same region\nconst bedrock = new BedrockRuntimeClient({ region: process.env.AWS_REGION });\n\n// Create a DynamoDB client that works with plain JavaScript objects\nconst ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region: process.env.AWS_REGION }));\n\n// The name of the DynamoDB table that stores users\nconst USER_TABLE = process.env.USER_TABLE!;\n\n/**\n * Lambda entry point – receives an HTTP request from API Gateway,\n * forwards it to Claude, handles any tool call, writes to DynamoDB,\n * and returns a JSON response.\n */\nexport const handler = async (\n  event: APIGatewayProxyEvent\n): Promise<APIGatewayProxyResult> => {\n  // 1️⃣ Parse the incoming body – we expect `{ \"name\": \"Alice\" }`\n  const body = event.body ? JSON.parse(event.body) : {};\n\n  // 2️⃣ Build the chat request with our tool definition (see next section)\n  const chatPayload = buildChatPayload(body.name);\n\n  // 3️⃣ Choose streaming or not based on a query param\n  const useStream = event.queryStringParameters?.stream === 'true';\n\n  // 4️⃣ Call Bedrock\n  const response = useStream\n    ? await invokeClaudeStream(chatPayload)\n    : await invokeClaudeOnce(chatPayload);\n\n  // 5️⃣ If Claude asked us to run a tool, handle it\n  if (response.toolCall) {\n    await handleToolCall(response.toolCall);\n    // After the side‑effect we can return a simple confirmation\n    return {\n      statusCode: 200,\n      body: JSON.stringify({ message: 'User added', user: response.toolCall.arguments }),\n    };\n  }\n\n  // 6️⃣ No tool call – just forward Claude’s answer\n  return {\n    statusCode: 200,\n    body: JSON.stringify({ answer: response.content }),\n  };\n};\n```\n\n**Explanation of the lines**\n\n`import …`\n\nbrings in the AWS SDKs we need. The Bedrock client lives in `@aws-sdk/client-bedrock-runtime`\n\n; the DynamoDB client lives in `@aws-sdk/lib-dynamodb`\n\n.`bedrock`\n\nand `ddb`\n\nare instantiated once per Lambda container – cheap and fast.`handler`\n\nis the function AWS calls for every request.`add_user`\n\ntool (the tool definition lives in `buildChatPayload`\n\n– see next section).`?stream=true`\n\n). This is useful for UI work, but also triggers the “toolResult” shape difference we’ll discuss later.\n\nTakeaway:A Lambda can stay completely stateless – it only needs the request payload, the model’s answer, and a write to DynamoDB.\n\nWhy include a *structured prompt*? Claude needs to know two things: (1) the conversation so far, and (2) the list of tools it may call. The tool definition follows the OpenAI‑compatible “function” schema, which Bedrock also understands.\n\n``` js\n// src/tools.ts\nexport const addUserTool = {\n  name: 'add_user',\n  description: 'Add a new user record to the Users table.',\n  // JSON Schema describing the arguments the model should supply\n  parameters: {\n    type: 'object',\n    properties: {\n      userId: { type: 'string', description: 'A UUID for the new user' },\n      name: { type: 'string', description: 'Full name of the user' },\n      email: { type: 'string', format: 'email', description: 'User email address' },\n    },\n    required: ['userId', 'name', 'email'],\n    additionalProperties: false,\n  },\n} as const;\n```\n\n`as const`\n\ntells TypeScript to treat the object as a literal, which later helps us build a type‑safe payload.\n\n``` js\n// src/chatPayload.ts\nimport { addUserTool } from './tools';\n\nexport function buildChatPayload(name: string) {\n  return {\n    // The model we want to run – Claude‑3‑Sonnet‑20240229 is a good default\n    modelId: 'anthropic.claude-3-sonnet-20240229-v1:0',\n    // Enable streaming if you prefer; leave false for simple JSON\n    body: JSON.stringify({\n      // System prompt – explains the assistant’s role\n      system: 'You are a helpful assistant that can add users to a database.',\n      // The user message that triggered the request\n      messages: [{ role: 'user', content: `Add a user named ${name}` }],\n      // Tell Claude about the tool we expose\n      tools: [addUserTool],\n    }),\n    // Content‑type required by Bedrock\n    contentType: 'application/json',\n    accept: 'application/json',\n  };\n}\n```\n\nAnalogy:Imagine you are a chef (Claude) and you have a pantry (the tool list). By showing the pantry items, the chef knows which ingredients they can pull out to fulfill a request.\n\n``` js\n// src/invokeClaude.ts\nimport {\n  BedrockRuntimeClient,\n  InvokeModelWithResponseStreamCommand,\n  InvokeModelCommand,\n} from '@aws-sdk/client-bedrock-runtime';\n\n/**\n * Non‑streaming call – returns a single JSON object.\n */\nexport async function invokeClaudeOnce(payload: any) {\n  const command = new InvokeModelCommand({\n    modelId: payload.modelId,\n    body: payload.body,\n    contentType: payload.contentType,\n    accept: payload.accept,\n  });\n  const { body } = await bedrock.send(command);\n  const raw = Buffer.from(body as Uint8Array).toString('utf8');\n  const parsed = JSON.parse(raw);\n\n  // Bedrock puts the model’s answer under `completion` and, if a tool was called,\n  // under `toolResult`. We normalise both shapes here.\n  return normalizeBedrockResponse(parsed);\n}\n\n/**\n * Streaming call – Bedrock sends a series of SSE events.\n */\nexport async function invokeClaudeStream(payload: any) {\n  const command = new InvokeModelWithResponseStreamCommand({\n    modelId: payload.modelId,\n    body: payload.body,\n    contentType: payload.contentType,\n    accept: payload.accept,\n  });\n  const response = await bedrock.send(command);\n\n  // The response is a readable stream of Uint8Array chunks.\n  // We need to concatenate them, split on newlines, and parse each SSE.\n  const chunks: Uint8Array[] = [];\n  for await (const chunk of response.body!) {\n    chunks.push(chunk);\n  }\n  const raw = Buffer.concat(chunks).toString('utf8');\n\n  // SSE format: each line starts with \"data: \" followed by JSON.\n  const events = raw\n    .split('\\n')\n    .filter((line) => line.startsWith('data: '))\n    .map((line) => JSON.parse(line.replace(/^data: /, '')));\n\n  // The last event usually contains the final model output.\n  const final = events[events.length - 1];\n  return normalizeBedrockResponse(final);\n}\n\n/**\n * Helper – turns both streaming and non‑streaming shapes into a unified object.\n */\nfunction normalizeBedrockResponse(raw: any) {\n  // Non‑streaming: `completion` holds the text, `toolResult` may exist\n  if (raw.completion) {\n    return {\n      content: raw.completion,\n      toolCall: raw.toolResult?.toolCall ?? null,\n    };\n  }\n  // Streaming: `output` holds the same structure\n  if (raw.output) {\n    return {\n      content: raw.output?.content?.[0]?.text ?? '',\n      toolCall: raw.output?.toolResult?.toolCall ?? null,\n    };\n  }\n  // Fallback – no known shape\n  return { content: '', toolCall: null };\n}\n```\n\n**Key points**\n\n`data:`\n\nlines.`normalizeBedrockResponse`\n\nfunction hides the “toolResult only appears when `stream=true`\n\n” gotcha.`{ content, toolCall }`\n\n) the rest of the Lambda can stay agnostic.\n\nTip:If you see`toolResult`\n\nmissing even though your prompt asked for a function, double‑check that you passed`stream=true`\n\n. Bedrock’s non‑streaming response hides the field.\n\nWhy separate the tool handling? It keeps the Lambda’s main flow readable and lets you reuse the same logic in other entry points (e.g., SQS workers).\n\n```\n// src/types.ts\nexport interface AddUserArgs {\n  userId: string;\n  name: string;\n  email: string;\n}\n\n// A discriminated union that can grow with more tools later\nexport type ToolCall =\n  | { name: 'add_user'; arguments: AddUserArgs }\n  // | { name: 'other_tool'; arguments: OtherArgs };\njs\n// src/handleTool.ts\nimport { PutCommand } from '@aws-sdk/lib-dynamodb';\nimport { ddb, USER_TABLE } from './lambda'; // re‑use the clients created earlier\nimport { ToolCall } from './types';\n\n/**\n * Executes the side‑effect requested by Claude.\n * Right now we only support `add_user`, but the pattern scales.\n */\nexport async function handleToolCall(tool: ToolCall) {\n  if (tool.name === 'add_user') {\n    // 1️⃣ Prepare the item for DynamoDB\n    const item = {\n      PK: `USER#${tool.arguments.userId}`, // Partition key pattern\n      SK: 'METADATA',\n      name: tool.arguments.name,\n      email: tool.arguments.email,\n      createdAt: new Date().toISOString(),\n    };\n\n    // 2️⃣ Write the item – PutCommand replaces any existing record with the same PK.\n    //    We could use TransactWriteItems for atomic multi‑item writes if needed.\n    await ddb.send(new PutCommand({ TableName: USER_TABLE, Item: item }));\n  } else {\n    // Future tools can be added here\n    throw new Error(`Unsupported tool: ${tool.name}`);\n  }\n}\n```\n\nIn plain English:The function looks at the name of the tool the model asked for, builds a DynamoDB record, and stores it. If the model asked for something we don’t know, we fail fast so the problem is visible.\n\nEven though this example writes a single item, at scale you may hit *hot partitions* (too many writes to the same partition key). A simple mitigation is to add a random suffix or a time‑bucket to the primary key. For a beginner prototype you can ignore it, but keep the warning in mind when you move to production.\n\nWhy test? The whole pipeline touches three services (API Gateway, Bedrock, DynamoDB). A short integration test catches mis‑typed JSON, missing environment variables, and the streaming‑shape bug we discussed.\n\n```\n# 1️⃣ Export required env vars\nexport AWS_REGION=us-east-1\nexport USER_TABLE=dev-Users\nexport AWS_ACCESS_KEY_ID=...\nexport AWS_SECRET_ACCESS_KEY=...\n\n# 2️⃣ Run the Lambda locally (SAM will spin up a tiny API Gateway)\nsam local invoke AddUserFunction -e events/add-user.json\n```\n\n`events/add-user.json`\n\n```\n{\n  \"body\": \"{\\\"name\\\":\\\"Bob\\\"}\",\n  \"queryStringParameters\": { \"stream\": \"false\" }\n}\n```\n\nYou should see a response similar to:\n\n```\n{\n  \"statusCode\": 200,\n  \"body\": \"{\\\"message\\\":\\\"User added\\\",\\\"user\\\":{\\\"userId\\\":\\\"c9b1…\\\",\\\"name\\\":\\\"Bob\\\",\\\"email\\\":\\\"bob@example.com\\\"}}\"\n}\njs\n// src/__tests__/normalize.test.ts\nimport { normalizeBedrockResponse } from '../invokeClaude';\n\ntest('handles streaming shape', () => {\n  const streamingRaw = {\n    output: {\n      content: [{ text: 'Done' }],\n      toolResult: { toolCall: { name: 'add_user', arguments: { userId: '1', name: 'Bob', email: 'bob@example.com' } } },\n    },\n  };\n  const result = normalizeBedrockResponse(streamingRaw);\n  expect(result.toolCall?.name).toBe('add_user');\n});\n\ntest('handles non‑streaming shape', () => {\n  const nonStreamingRaw = {\n    completion: 'Done',\n    toolResult: { toolCall: { name: 'add_user', arguments: { userId: '2', name: 'Alice', email: 'alice@example.com' } } },\n  };\n  const result = normalizeBedrockResponse(nonStreamingRaw);\n  expect(result.toolCall?.arguments.email).toBe('alice@example.com');\n});\n```\n\nRun with `npm test`\n\n. Both tests should pass, confirming that your Lambda will correctly detect tool calls regardless of the `stream`\n\nflag.\n\nKey takeaway:A tiny test suite that covers the two response shapes saves hours of debugging when you later switch from dev to production.\n\n`@aws-sdk/client-bedrock-runtime`\n\n) and the DynamoDB SDK (`@aws-sdk/lib-dynamodb`\n\n).`toolResult`\n\nfield appears only when `stream=true`\n\n. Normalize both shapes to keep the rest of your code simple.`add_user`\n\n). This isolates business logic and makes future extensions straightforward.With these pieces you can build real‑time AI‑driven APIs without a heavyweight micro‑service mesh. The pattern scales: add more tools, grow the DynamoDB table, and keep the Lambda stateless. Happy coding!\n\nTransparency noticeThis article was written with the help of an AI system —\n\n[Groq](GPT OSS 120B).\n\nPublished:2026-08-26 ·Primary focus:BedrockAll code blocks are intended to be correct and runnable, but please verify them\n\nagainst the official docs for the tools mentioned before using in production.\n\nFind an error? Drop a comment — corrections are always welcome.", "url": "https://wpnews.pro/news/claude-function-calling-on-bedrock-wiring-a-stateless-lambda-for-real-time-ai", "canonical_source": "https://dev.to/dineshgowtham/claude-function-calling-on-bedrock-wiring-a-stateless-lambda-for-real-time-ai-actions-39h1", "published_at": "2026-08-26 11:55:19+00:00", "updated_at": "2026-08-26 12:14:49.998228+00:00", "lang": "en", "topics": ["artificial-intelligence", "large-language-models", "ai-products", "developer-tools"], "entities": ["Claude", "Amazon Bedrock", "AWS Lambda", "DynamoDB", "Amazon API Gateway", "Node.js", "TypeScript"], "alternates": {"html": "https://wpnews.pro/news/claude-function-calling-on-bedrock-wiring-a-stateless-lambda-for-real-time-ai", "markdown": "https://wpnews.pro/news/claude-function-calling-on-bedrock-wiring-a-stateless-lambda-for-real-time-ai.md", "text": "https://wpnews.pro/news/claude-function-calling-on-bedrock-wiring-a-stateless-lambda-for-real-time-ai.txt", "jsonld": "https://wpnews.pro/news/claude-function-calling-on-bedrock-wiring-a-stateless-lambda-for-real-time-ai.jsonld"}}