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.
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.
In 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”.
Why 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.
| Piece | What it is | Why you need it |
|---|---|---|
| Tool definition | ||
| JSON that tells Bedrock what functions are available (name, description, parameter schema) | The model can only call functions it knows about | |
| ToolResult field | ||
| The part of the response that contains the model’s chosen function call | Without it, you never see the request to run your code | |
| Streaming vs non‑streaming | ||
stream=true returns a series of Server‑Sent Events (SSE); stream=false returns a single JSON payload |
||
| Streaming lets you forward the answer to the caller as soon as it’s ready, but changes the shape of the result |
Tip:The same TypeScript interface won’t match both shapes. Write two narrow types or a discriminated union and let the compiler help you.
A 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.
Why 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.
// src/lambda.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import {
BedrockRuntimeClient,
InvokeModelWithResponseStreamCommand,
InvokeModelCommand,
} from '@aws-sdk/client-bedrock-runtime';
import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb';
// Create a Bedrock client that talks to the Claude model in the same region
const bedrock = new BedrockRuntimeClient({ region: process.env.AWS_REGION });
// Create a DynamoDB client that works with plain JavaScript objects
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region: process.env.AWS_REGION }));
// The name of the DynamoDB table that stores users
const USER_TABLE = process.env.USER_TABLE!;
/**
* Lambda entry point – receives an HTTP request from API Gateway,
* forwards it to Claude, handles any tool call, writes to DynamoDB,
* and returns a JSON response.
*/
export const handler = async (
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
// 1️⃣ Parse the incoming body – we expect `{ "name": "Alice" }`
const body = event.body ? JSON.parse(event.body) : {};
// 2️⃣ Build the chat request with our tool definition (see next section)
const chatPayload = buildChatPayload(body.name);
// 3️⃣ Choose streaming or not based on a query param
const useStream = event.queryStringParameters?.stream === 'true';
// 4️⃣ Call Bedrock
const response = useStream
? await invokeClaudeStream(chatPayload)
: await invokeClaudeOnce(chatPayload);
// 5️⃣ If Claude asked us to run a tool, handle it
if (response.toolCall) {
await handleToolCall(response.toolCall);
// After the side‑effect we can return a simple confirmation
return {
statusCode: 200,
body: JSON.stringify({ message: 'User added', user: response.toolCall.arguments }),
};
}
// 6️⃣ No tool call – just forward Claude’s answer
return {
statusCode: 200,
body: JSON.stringify({ answer: response.content }),
};
};
Explanation of the lines
import …
brings in the AWS SDKs we need. The Bedrock client lives in @aws-sdk/client-bedrock-runtime
; the DynamoDB client lives in @aws-sdk/lib-dynamodb
.bedrock
and ddb
are instantiated once per Lambda container – cheap and fast.handler
is the function AWS calls for every request.add_user
tool (the tool definition lives in buildChatPayload
– see next section).?stream=true
). This is useful for UI work, but also triggers the “toolResult” shape difference we’ll discuss later.
Takeaway:A Lambda can stay completely stateless – it only needs the request payload, the model’s answer, and a write to DynamoDB.
Why 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.
// src/tools.ts
export const addUserTool = {
name: 'add_user',
description: 'Add a new user record to the Users table.',
// JSON Schema describing the arguments the model should supply
parameters: {
type: 'object',
properties: {
userId: { type: 'string', description: 'A UUID for the new user' },
name: { type: 'string', description: 'Full name of the user' },
email: { type: 'string', format: 'email', description: 'User email address' },
},
required: ['userId', 'name', 'email'],
additionalProperties: false,
},
} as const;
as const
tells TypeScript to treat the object as a literal, which later helps us build a type‑safe payload.
// src/chatPayload.ts
import { addUserTool } from './tools';
export function buildChatPayload(name: string) {
return {
// The model we want to run – Claude‑3‑Sonnet‑20240229 is a good default
modelId: 'anthropic.claude-3-sonnet-20240229-v1:0',
// Enable streaming if you prefer; leave false for simple JSON
body: JSON.stringify({
// System prompt – explains the assistant’s role
system: 'You are a helpful assistant that can add users to a database.',
// The user message that triggered the request
messages: [{ role: 'user', content: `Add a user named ${name}` }],
// Tell Claude about the tool we expose
tools: [addUserTool],
}),
// Content‑type required by Bedrock
contentType: 'application/json',
accept: 'application/json',
};
}
Analogy: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.
// src/invokeClaude.ts
import {
BedrockRuntimeClient,
InvokeModelWithResponseStreamCommand,
InvokeModelCommand,
} from '@aws-sdk/client-bedrock-runtime';
/**
* Non‑streaming call – returns a single JSON object.
*/
export async function invokeClaudeOnce(payload: any) {
const command = new InvokeModelCommand({
modelId: payload.modelId,
body: payload.body,
contentType: payload.contentType,
accept: payload.accept,
});
const { body } = await bedrock.send(command);
const raw = Buffer.from(body as Uint8Array).toString('utf8');
const parsed = JSON.parse(raw);
// Bedrock puts the model’s answer under `completion` and, if a tool was called,
// under `toolResult`. We normalise both shapes here.
return normalizeBedrockResponse(parsed);
}
/**
* Streaming call – Bedrock sends a series of SSE events.
*/
export async function invokeClaudeStream(payload: any) {
const command = new InvokeModelWithResponseStreamCommand({
modelId: payload.modelId,
body: payload.body,
contentType: payload.contentType,
accept: payload.accept,
});
const response = await bedrock.send(command);
// The response is a readable stream of Uint8Array chunks.
// We need to concatenate them, split on newlines, and parse each SSE.
const chunks: Uint8Array[] = [];
for await (const chunk of response.body!) {
chunks.push(chunk);
}
const raw = Buffer.concat(chunks).toString('utf8');
// SSE format: each line starts with "data: " followed by JSON.
const events = raw
.split('\n')
.filter((line) => line.startsWith('data: '))
.map((line) => JSON.parse(line.replace(/^data: /, '')));
// The last event usually contains the final model output.
const final = events[events.length - 1];
return normalizeBedrockResponse(final);
}
/**
* Helper – turns both streaming and non‑streaming shapes into a unified object.
*/
function normalizeBedrockResponse(raw: any) {
// Non‑streaming: `completion` holds the text, `toolResult` may exist
if (raw.completion) {
return {
content: raw.completion,
toolCall: raw.toolResult?.toolCall ?? null,
};
}
// Streaming: `output` holds the same structure
if (raw.output) {
return {
content: raw.output?.content?.[0]?.text ?? '',
toolCall: raw.output?.toolResult?.toolCall ?? null,
};
}
// Fallback – no known shape
return { content: '', toolCall: null };
}
Key points
data:
lines.normalizeBedrockResponse
function hides the “toolResult only appears when stream=true
” gotcha.{ content, toolCall }
) the rest of the Lambda can stay agnostic.
Tip:If you seetoolResult
missing even though your prompt asked for a function, double‑check that you passedstream=true
. Bedrock’s non‑streaming response hides the field.
Why 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).
// src/types.ts
export interface AddUserArgs {
userId: string;
name: string;
email: string;
}
// A discriminated union that can grow with more tools later
export type ToolCall =
| { name: 'add_user'; arguments: AddUserArgs }
// | { name: 'other_tool'; arguments: OtherArgs };
js
// src/handleTool.ts
import { PutCommand } from '@aws-sdk/lib-dynamodb';
import { ddb, USER_TABLE } from './lambda'; // re‑use the clients created earlier
import { ToolCall } from './types';
/**
* Executes the side‑effect requested by Claude.
* Right now we only support `add_user`, but the pattern scales.
*/
export async function handleToolCall(tool: ToolCall) {
if (tool.name === 'add_user') {
// 1️⃣ Prepare the item for DynamoDB
const item = {
PK: `USER#${tool.arguments.userId}`, // Partition key pattern
SK: 'METADATA',
name: tool.arguments.name,
email: tool.arguments.email,
createdAt: new Date().toISOString(),
};
// 2️⃣ Write the item – PutCommand replaces any existing record with the same PK.
// We could use TransactWriteItems for atomic multi‑item writes if needed.
await ddb.send(new PutCommand({ TableName: USER_TABLE, Item: item }));
} else {
// Future tools can be added here
throw new Error(`Unsupported tool: ${tool.name}`);
}
}
In 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.
Even 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.
Why 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.
export AWS_REGION=us-east-1
export USER_TABLE=dev-Users
export AWS_ACCESS_KEY_ID=...
export AWS_SECRET_ACCESS_KEY=...
sam local invoke AddUserFunction -e events/add-user.json
events/add-user.json
{
"body": "{\"name\":\"Bob\"}",
"queryStringParameters": { "stream": "false" }
}
You should see a response similar to:
{
"statusCode": 200,
"body": "{\"message\":\"User added\",\"user\":{\"userId\":\"c9b1…\",\"name\":\"Bob\",\"email\":\"bob@example.com\"}}"
}
js
// src/__tests__/normalize.test.ts
import { normalizeBedrockResponse } from '../invokeClaude';
test('handles streaming shape', () => {
const streamingRaw = {
output: {
content: [{ text: 'Done' }],
toolResult: { toolCall: { name: 'add_user', arguments: { userId: '1', name: 'Bob', email: 'bob@example.com' } } },
},
};
const result = normalizeBedrockResponse(streamingRaw);
expect(result.toolCall?.name).toBe('add_user');
});
test('handles non‑streaming shape', () => {
const nonStreamingRaw = {
completion: 'Done',
toolResult: { toolCall: { name: 'add_user', arguments: { userId: '2', name: 'Alice', email: 'alice@example.com' } } },
};
const result = normalizeBedrockResponse(nonStreamingRaw);
expect(result.toolCall?.arguments.email).toBe('alice@example.com');
});
Run with npm test
. Both tests should pass, confirming that your Lambda will correctly detect tool calls regardless of the stream
flag.
Key takeaway:A tiny test suite that covers the two response shapes saves hours of debugging when you later switch from dev to production.
@aws-sdk/client-bedrock-runtime
) and the DynamoDB SDK (@aws-sdk/lib-dynamodb
).toolResult
field appears only when stream=true
. Normalize both shapes to keep the rest of your code simple.add_user
). 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!
Transparency noticeThis article was written with the help of an AI system —
[Groq](GPT OSS 120B).
Published:2026-08-26 ·Primary focus:BedrockAll 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.