AI agents are great at automating multi-step workflows, but when they start writing to a database you'll quickly hit race‑conditions and silent data loss. In this article we show how to wire Claude's tool‑calling into a Lambda that updates DynamoDB with proper idempotency and observability.
Why AI Agents Need Transactional Guarantees
When a language model (LLM) like Claude decides to “create a user” it usually follows three steps:
- Plan – decide what data is needed.
- Act – call an external tool (here a Lambda) to write the data.
- Observe – read the result and decide what to do next.
If the write step is not protected by a transaction‑like guarantee, two separate plans can race each other:
Plan A and Plan B both think the userId is free, call the Lambda at the same time, and the later write silently overwrites the earlier one. The LLM never sees the conflict because DynamoDB by default returns the new item, not the fact that it replaced something.
In plain English: Think of a bank teller who writes a check without looking at the ledger first. Two tellers could give the same account two different checks, and the bank would lose money. A transaction is the ledger that stops that from happening.
The concrete problem: race‑conditions and silent loss
- Race‑condition – two concurrent operations interfere because they read the same stale state.
- Silent data loss – the later operation succeeds, but the earlier one’s intent disappears without any error.
DynamoDB offers conditional writes (a kind of lightweight transaction) that let us say “only write if this attribute does not already exist.” Coupled with proper retry logic, we get the same safety net a relational database’s INSERT … ON CONFLICT gives.
Key takeaway: Without a guard that checks the current state, an AI‑driven workflow can produce hidden bugs that are hard to debug later.
Setting Up Claude's Tool Call to Invoke a Lambda
Claude’s tool‑calling feature works like a contract: the model describes a function signature, then later fills in the arguments and asks the host to run it. For our use case we expose a Lambda function called saveUser.
1. Define the tool spec for Claude
{
"name": "saveUser",
"description": "Persist a new user record in DynamoDB",
"parameters": {
"type": "object",
"properties": {
"userId": { "type": "string", "description": "Unique identifier for the user" },
"email": { "type": "string", "description": "User's email address" },
"name": { "type": "string", "description": "Full name of the user" }
},
"required": ["userId", "email", "name"]
}
}
-
Tool spec – a JSON description that tells Claude what arguments it may pass. The model treats
saveUseras a black‑box it can call when needed.
2. Wire the spec to a Lambda invocation
When Claude decides to call saveUser, your application receives a payload that looks like:
{
"name": "saveUser",
"arguments": {
"userId": "u-12345",
"email": "alice@example.com",
"name": "Alice Doe"
}
}
The following Node.js snippet shows how to forward that request to a Lambda using the AWS SDK client @aws-sdk/client-lambda.
// file: invokeClaudeTool.ts
import { LambdaClient, InvokeCommand } from "@aws-sdk/client-lambda";
// Create a Lambda client that will run in the same region as your function.
const lambda = new LambdaClient({ region: "us-east-1" });
/**
* Calls the Lambda that actually writes to DynamoDB.
* @param payload – the JSON object Claude gave us.
* @returns parsed JSON response from the Lambda.
*/
export async function callSaveUserTool(payload: {
userId: string;
email: string;
name: string;
}) {
// The Lambda we created in step 3 below.
const functionName = "saveUser";
// The Lambda expects the same shape, so we JSON‑stringify it.
const input = {
FunctionName: functionName,
InvocationType: "RequestResponse", // wait for the result
Payload: Buffer.from(JSON.stringify(payload)),
};
// Send the request to AWS.
const command = new InvokeCommand(input);
const response = await lambda.send(command);
// The Lambda returns a Uint8Array; turn it back into an object.
const result = JSON.parse(Buffer.from(response.Payload!).toString());
return result;
}
- InvocationType = "RequestResponse" – tells Lambda to run synchronously and give us the result immediately, which is what Claude expects.
Tip: In Node 22,
require('esm')can break Lambda layers that still rely on CommonJS. Stick to native ESM (import) for new projects.
Implementing Idempotent DynamoDB Writes with Conditional Expressions
What does idempotent mean?
An operation is idempotent when running it multiple times with the same input leaves the system in the same state as running it once. For a user‑creation API, that means “if the user already exists, tell me so instead of creating a duplicate.”
Using a ConditionalExpression
DynamoDB’s ConditionExpression lets us say “only put this item if userId does not already exist.” If the condition fails, DynamoDB throws a ConditionalCheckFailedException. We can catch that exception and translate it into a ConflictError that Claude can understand.
Below is a minimal Lambda handler that does exactly that, using the @aws-sdk/lib-dynamodb package which provides a higher‑level DynamoDBDocumentClient.
// file: saveUser.ts
import {
DynamoDBDocumentClient,
PutCommand,
PutCommandInput,
} from "@aws-sdk/lib-dynamodb";
import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
// The low‑level client is created once per container (cold start) for efficiency.
const ddbClient = DynamoDBDocumentClient.from(
// The default low‑level client reads credentials from the Lambda execution role.
new DynamoDBClient({})
);
/**
* Lambda entry point – receives the JSON payload from Claude.
*/
export const handler = async (
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
// Parse the incoming body – Claude sends a JSON string.
const body = JSON.parse(event.body ?? "{}");
const { userId, email, name } = body;
// Build the PutCommand with a condition that the key must not exist.
const params: PutCommandInput = {
TableName: "Users",
Item: { userId, email, name, createdAt: new Date().toISOString() },
ConditionExpression: "attribute_not_exists(userId)", // <-- guard
ReturnValues: "ALL_OLD", // we ask for the previous item just in case.
};
try {
// Try to write. If the condition passes, DynamoDB returns {}.
await ddbClient.send(new PutCommand(params));
// Success – return the newly created item.
return {
statusCode: 200,
body: JSON.stringify({ success: true, item: params.Item }),
};
} catch (err: any) {
// DynamoDB uses a specific error name for condition failures.
if (err.name === "ConditionalCheckFailedException") {
// Translate into a format Claude can handle.
return {
statusCode: 409,
body: JSON.stringify({
error: "ConflictError",
message: `User with userId ${userId} already exists`,
}),
};
}
// Unexpected errors bubble up – useful for observability.
console.error("Unexpected DynamoDB error:", err);
return {
statusCode: 500,
body: JSON.stringify({ error: "InternalError", details: err.message }),
};
}
};
Why the ReturnValues='ALL_OLD' gotcha matters
If we retry after a conflict, DynamoDB will still return the old item, making it look like the write succeeded. By explicitly checking the exception name we avoid hiding the conflict.
In plain English: Imagine a door with a lock that only opens if the lock is currently unlocked. If someone else already turned the key, the lock stays closed and tells you “can't open”. If you ignore that message, you might think the door opened when it didn’t.
Adding a retry wrapper
Claude may decide to retry when it sees a 409 ConflictError. To make retries safe, we keep the same payload and let DynamoDB’s condition do the work. No extra state is needed.
// file: retryWrapper.ts
import { callSaveUserTool } from "./invokeClaudeTool";
/**
* Calls the saveUser Lambda and retries on conflict up to `maxAttempts`.
*/
export async function saveUserWithRetry(
user: { userId: string; email: string; name: string },
maxAttempts = 3
) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const result = await callSaveUserTool(user);
if (result.success) {
return result; // success on first try or after a retry
}
if (result.error === "ConflictError") {
// Conflict is expected; we can decide to abort or continue.
console.warn(
`Attempt ${attempt} hit a conflict for userId ${user.userId}`
);
// In many workflows we stop after the first conflict because the user already exists.
break;
}
// For any other error we might want to back‑off and retry.
console.error(`Attempt ${attempt} failed:`, result);
await new Promise((r) => setTimeout(r, 100 * attempt));
}
throw new Error(`Failed to save user ${user.userId} after ${maxAttempts} attempts`);
}
Tip: DynamoDB pricing at scale (
$0.25per Write Capacity Unit) can surprise teams that used RDS. Using conditional writes reduces wasted capacity because failed writes don’t consume a write unit.
Observing Agent‑DB Interactions via diagnostics_channel
Why observability matters
Even with guards, you still want to know when the agent called the Lambda, what it tried to write, and whether DynamoDB accepted it. Node’s built‑in diagnostics_channel lets you emit and listen to custom events without adding heavy tracing libraries.
Emitting events from the Lambda
// file: saveUser.ts (add to the top)
import { channel } from "diagnostics_channel";
const dbChannel = channel("agent-db-interaction");
// Inside the successful branch, after the PutCommand:
dbChannel.publish({
action: "put",
table: "Users",
key: { userId },
status: "success",
});
// Inside the conflict catch block:
dbChannel.publish({
action: "put",
table: "Users",
key: { userId },
status: "conflict",
});
Listening in the caller process
// file: monitor.ts
import { channel } from "diagnostics_channel";
const dbChannel = channel("agent-db-interaction");
// Register a listener early in the application lifecycle.
dbChannel.subscribe((msg) => {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] DB ${msg.action} on ${msg.table}`, msg);
});
The subscription prints a line every time the Lambda runs, giving you a lightweight audit log that can be forwarded to CloudWatch or a third‑party observability platform.
Key takeaway:
diagnostics_channelworks like a walkie‑talkie between the Lambda and the part of your service that orchestrates the agent, letting you see exactly what happened without changing any business logic.
Testing the Full Plan‑Act‑Observe Loop
A solid test suite proves that the agent’s plan, the Lambda’s action, and the diagnostics all play nicely together.
1. Mock Claude’s tool request
We’ll use a simple in‑process test rather than a full end‑to‑end deployment, which keeps the test fast and cheap.
// file: __tests__/fullLoop.test.ts
import { handler as saveUserHandler } from "../saveUser";
import { APIGatewayProxyEvent } from "aws-lambda";
import { channel } from "diagnostics_channel";
describe("Full Claude → Lambda → DynamoDB loop", () => {
const events: any[] = [];
// Capture diagnostics events.
beforeAll(() => {
const dbChannel = channel("agent-db-interaction");
dbChannel.subscribe((msg) => events.push(msg));
});
test("creates a user and records diagnostics", async () => {
const payload = {
userId: "test-001",
email: "bob@example.com",
name: "Bob Builder",
};
const event: APIGatewayProxyEvent = {
body: JSON.stringify(payload),
// other required fields can be stubbed with empty strings/objects
headers: {},
multiValueHeaders: {},
httpMethod: "POST",
isBase64Encoded: false,
path: "/",
pathParameters: null,
queryStringParameters: null,
multiValueQueryStringParameters: null,
stageVariables: null,
requestContext: {} as any,
resource: "",
};
const result = await saveUserHandler(event);
const body = JSON.parse(result.body);
// Verify Lambda succeeded.
expect(result.statusCode).toBe(200);
expect(body.success).toBe(true);
expect(body.item.userId).toBe(payload.userId);
// Verify diagnostics captured a success event.
expect(events).toContainEqual(
expect.objectContaining({
action: "put",
table: "Users",
key: { userId: payload.userId },
status: "success",
})
);
});
test("handles duplicate userId as ConflictError", async () => {
const payload = {
userId: "test-001", // same as previous test
email: "bob2@example.com",
name: "Bob Duplicate",
};
const event = {
body: JSON.stringify(payload),
headers: {},
multiValueHeaders: {},
httpMethod: "POST",
isBase64Encoded: false,
path: "/",
pathParameters: null,
queryStringParameters: null,
multiValueQueryStringParameters: null,
stageVariables: null,
requestContext: {} as any,
resource: "",
} as APIGatewayProxyEvent;
const result = await saveUserHandler(event);
const body = JSON.parse(result.body);
expect(result.statusCode).toBe(409);
expect(body.error).toBe("ConflictError");
// Verify diagnostics captured a conflict event.
expect(events).toContainEqual(
expect.objectContaining({
action: "put",
table: "Users",
key: { userId: payload.userId },
status: "conflict",
})
);
});
});
2. Run the test locally
npm install --save-dev jest @types/jest ts-node
npx jest
The test confirms three things:
- The Lambda can create a new user (the happy path).
- A second call with the same
userIdyields a ConflictError rather than silently overwriting. - The
diagnostics_channelevents line up with the actual outcome, giving us a reliable observability hook.
Tip: Hot partitions are still a thing in 2025. If you see throttling in your test logs, add a random suffix to the partition key (e.g.,
userId =${teamId}#${uuid}`) to spread load across multiple partitions.
The Takeaway
In plain English: By combining Claude’s tool‑calling, a Lambda with a conditional DynamoDB write, and lightweight diagnostics, you can build an AI‑driven workflow that never silently clobbers data.
-
Transactional safety comes from DynamoDB’s
ConditionExpressionthat blocks duplicate writes. - Idempotent design means the same payload can be retried without side effects.
-
Tool‑calling contract gives Claude a clear, typed interface (
saveUser) it can invoke without guessing. -
Observability via
diagnostics_channelprovides a low‑overhead audit trail for every agent‑DB interaction. - Testing the whole loop locally catches race‑condition bugs before they hit production.
- Operational awareness (hot partitions, pricing, TTL delays) prevents surprises when the system scales.
With these patterns in place, you can let Claude handle the “thinking” part of your workflow while your infrastructure guarantees that the data it writes stays consistent and visible. Happy coding!
Transparency notice
This article was written with the help of an AI system — Groq (GPT OSS 120B).
Published: 2026-09-22 · Primary focus: DynamoDB
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)