DynamoDB Transactions for Coding Agents: Atomic Tool Ledgers and Idempotent Tool Calls

19 views

Your coding agent opened the same pull request twice. Nobody clicked twice and the model didn’t “change its mind”. A Lambda function timed out after the GitHub call succeeded, Step Functions retried the task, and the tool ran again. Somewhere else, a tenant’s token quota dropped by twice the real usage because the debit and the “effect applied” record were two separate writes and only one of them was retried.

Agents live in an at-least-once world: SDKs retry throttled calls, orchestrators retry failed tasks, and models sometimes call the same tool again for the same intent. Exactly-once delivery isn’t on offer. What you can build is exactly-once effect: every mutating tool call carries a stable key, and the write that records the effect is conditional on that key, so a retry becomes a harmless replay. This guide shows how to do that with Amazon DynamoDB conditional writes and transactions, including how to thread the key through Amazon Bedrock Agents. It merges our earlier posts on idempotent tool calls into this one.

⚡ TL;DR
  • Give every mutating tool call a stable idempotency key that is created before the call (by the planner or the client), never inside the tool on each attempt.
  • Use one TransactWriteItems call to debit the quota, insert the ledger row with attribute_not_exists, and update session state. It is all-or-nothing: up to 100 actions, 4 MB, same account and Region.
  • If the transaction is canceled with ConditionalCheckFailed on the ledger row, the effect was already recorded: return the stored result instead of failing.
  • For irreversible tools (git push, ticket creation), reserve a pending row first, perform the action, then finalize; sweep stale reservations.
  • Watch the details: cursor, status and result are DynamoDB reserved words, there is no separate IAM action for transactions, and TTL deletes aren’t immediate.

Who this is for: engineers running agent tools on Lambda, Step Functions or Bedrock Agents action groups. Examples use the AWS SDK for JavaScript v3 in TypeScript; the same calls exist in every AWS SDK.

Why separate writes break under retries

A typical tool runner does three things after a tool succeeds: subtract the cost from the tenant’s quota, record that the effect happened, and move the session forward. Written as three separate PutItem/UpdateItem calls, each crash point leaves a different mess:

  • Crash after the debit: the tenant paid for an effect that wasn’t recorded, and the retry pays again.
  • Crash after the ledger write: the effect is recorded but the session cursor is stale, so the planner issues the step again.
  • No uniqueness on the ledger row (for example, a sort key built from Date.now()): every retry inserts a new “applied” row.

A conditional write on a stable key fixes the uniqueness problem. A transaction fixes the partial-write problem. Most agent tool ledgers need both.

How DynamoDB transactions work

TransactWriteItems groups up to 100 write actions (Put, Update, Delete and ConditionCheck) into one all-or-nothing operation. The actions can target up to 100 distinct items in one or more tables in the same AWS account and Region, and the aggregate item size can’t exceed 4 MB. The rules that matter for tool ledgers:

  • One action per item. You can’t target the same item twice in one transaction (for example, a ConditionCheck and an Update on the same key).
  • Ordered cancellation reasons. If any condition fails, the whole transaction is canceled with TransactionCanceledException, and CancellationReasons lists one entry per action in request order. Unaffected actions report the code None.
  • Idempotency token. A ClientRequestToken makes repeated identical calls have the effect of one call. A token is valid for 10 minutes after the request that uses it finishes; reusing it with different parameters inside that window returns IdempotentParameterMismatch.
  • Cost. DynamoDB performs two underlying writes per item (prepare and commit), and a canceled transaction still consumes capacity for the items it attempted. Plan capacity for that.
  • Streams. Changes appear in DynamoDB Streams, but stream consumers shouldn’t assume transaction atomicity or ordering.

The 10-minute ClientRequestToken window protects against fast SDK retries. It doesn’t replace your own idempotency key, because orchestrator retries and replays can arrive much later. Use both.

Figure 1. One TransactWriteItems call commits the quota debit, the ledger row and the session update together. A replay is detected by the ledger condition and returns the stored result. Diagram by CheatCoders.
Figure 1. One TransactWriteItems call commits the quota debit, the ledger row and the session update together. A replay is detected by the ledger condition and returns the stored result. Diagram by CheatCoders.

Step 1: Design keys around a stable tool call ID

The key must be identical across every retry of the same intended action, so it has to exist before the first attempt. Good sources, in order of preference:

  1. The client or UI creates a key per user intent (“open a PR for this branch”) and passes it along.
  2. The planner or orchestrator creates it when it schedules a step and stores it in workflow state, so a retried step reuses it.
  3. The model’s tool request ID (for example the Converse toolUseId) is stable for that one request, but if the model calls the tool again for the same intent it gets a new ID. Use it only for single-hop deduplication.

Never call randomUUID() inside the tool on each invocation; that guarantees a new key per retry. A single table works well for the three item types:

typescript
// Single-table layout for tool side effects
// Ledger:  pk = TENANT#acme#SESSION#s1   sk = TOOL#<toolCallId>
// Quota:   pk = TENANT#acme              sk = QUOTA#2026-10
// Session: pk = TENANT#acme#SESSION#s1   sk = META

export type LedgerItem = {
  pk: string;
  sk: string;
  toolName: string;
  state: "pending" | "applied" | "failed";
  costTokens: number;
  resultJson?: string;   // what to return on replay
  createdAt: string;
  expiresAt?: number;    // epoch seconds, for TTL
};

Step 2: Commit quota, ledger and session in one transaction

This is the core write. The quota update has a condition so a tenant can’t go below zero, the ledger put is conditional on the row not existing, and the session update moves the cursor. Note the attribute name placeholders: CURSOR is a DynamoDB reserved word, so SET cursor = :c fails validation. The same applies to STATUS, RESULT and TTL, which is why the item uses state, resultJson and expiresAt.

typescript
import {
  DynamoDBClient,
  TransactWriteItemsCommand,
  TransactionCanceledException,
} from "@aws-sdk/client-dynamodb";

const ddb = new DynamoDBClient({});
const TABLE = process.env.AGENT_TABLE ?? "AgentRuntime";

export type CommitResult =
  | { applied: true; duplicate: false }
  | { applied: true; duplicate: true; resultJson?: string }
  | { applied: false; reason: "quota_exhausted" };

export async function commitToolEffect(o: {
  tenantId: string;
  sessionId: string;
  toolCallId: string;
  toolName: string;
  costTokens: number;
  resultJson: string;
  nextCursor: string;
}): Promise<CommitResult> {
  const sessionPk = `TENANT#${o.tenantId}#SESSION#${o.sessionId}`;
  const month = new Date().toISOString().slice(0, 7);
  const now = new Date().toISOString();
  try {
    await ddb.send(new TransactWriteItemsCommand({
      ClientRequestToken: o.toolCallId.slice(0, 36), // max 36 characters
      TransactItems: [
        { Update: {
            TableName: TABLE,
            Key: { pk: { S: `TENANT#${o.tenantId}` }, sk: { S: `QUOTA#${month}` } },
            UpdateExpression: "SET remainingTokens = remainingTokens - :c",
            ConditionExpression: "remainingTokens >= :c",
            ExpressionAttributeValues: { ":c": { N: String(o.costTokens) } },
        } },
        { Put: {
            TableName: TABLE,
            Item: {
              pk: { S: sessionPk },
              sk: { S: `TOOL#${o.toolCallId}` },
              toolName: { S: o.toolName },
              state: { S: "applied" },
              costTokens: { N: String(o.costTokens) },
              resultJson: { S: o.resultJson },
              createdAt: { S: now },
            },
            ConditionExpression: "attribute_not_exists(pk)",
            ReturnValuesOnConditionCheckFailure: "ALL_OLD",
        } },
        { Update: {
            TableName: TABLE,
            Key: { pk: { S: sessionPk }, sk: { S: "META" } },
            UpdateExpression: "SET #cursor = :next, updatedAt = :now",
            ExpressionAttributeNames: { "#cursor": "cursor" },
            ExpressionAttributeValues: { ":next": { S: o.nextCursor }, ":now": { S: now } },
        } },
      ],
    }));
    return { applied: true, duplicate: false };
  } catch (err) {
    if (err instanceof TransactionCanceledException) {
      const reasons = err.CancellationReasons ?? [];
      if (reasons[1]?.Code === "ConditionalCheckFailed") {
        // Already applied: replay the stored result instead of failing.
        return { applied: true, duplicate: true, resultJson: reasons[1].Item?.resultJson?.S };
      }
      if (reasons[0]?.Code === "ConditionalCheckFailed") {
        return { applied: false, reason: "quota_exhausted" };
      }
    }
    throw err; // TransactionConflict, throttling, validation: let the caller retry or alert
  }
}

Two notes. ReturnValuesOnConditionCheckFailure: "ALL_OLD" on the ledger put returns the existing row inside the cancellation reason, so a replay can hand back the original result without another read. And the ClientRequestToken has a 36-character maximum, so use a key format that fits (a UUID does) or hash longer keys.

A ConditionalCheckFailed on the ledger row is not an error. It means the work is done. Treat it as success with duplicate: true, emit a metric, and return the stored result.

Step 3: Reserve, act, finalize for irreversible tools

Step 2 records the effect after it happened. That’s fine for reversible or naturally idempotent tools (writing a file, setting a label). For tools you can’t undo or safely repeat, such as pushing a branch, opening a ticket or calling a paid API, reserve first:

  1. Reserve: in one transaction, debit the quota and put the ledger row with state = "pending", conditional on the row not existing (or on an expired reservation, see below).
  2. Act: perform the external call, passing the same idempotency key to the external system if it supports one.
  3. Finalize: update pending to applied with the result, conditional on the state still being pending.
typescript
import { UpdateItemCommand } from "@aws-sdk/client-dynamodb";

export async function finalize(ddbClient: DynamoDBClient, table: string,
                               sessionPk: string, toolCallId: string, resultJson: string) {
  await ddbClient.send(new UpdateItemCommand({
    TableName: table,
    Key: { pk: { S: sessionPk }, sk: { S: `TOOL#${toolCallId}` } },
    UpdateExpression: "SET #state = :applied, resultJson = :r",
    ConditionExpression: "#state = :pending",   // a second finalizer loses cleanly
    ExpressionAttributeNames: { "#state": "state" },
    ExpressionAttributeValues: {
      ":applied": { S: "applied" },
      ":pending": { S: "pending" },
      ":r": { S: resultJson },
    },
  }));
}

A retry that finds a pending row must not act again blindly. It should either wait and re-read, or check the external system for the effect (for example, “does a PR for this branch already exist?”) and then finalize. A scheduled sweeper handles reservations that stay pending past a deadline: in one transaction it marks the row failed (conditional on pending) and refunds the quota. Without a sweeper, tenants slowly lose budget to crashed calls.

Step 4: Thread the key through Bedrock Agents action groups

When a Bedrock agent invokes an action group, your Lambda function receives an event with sessionId, actionGroup, apiPath and httpMethod (for API-schema action groups) or function (for function-detail action groups), plus parameters, an optional requestBody, sessionAttributes and promptSessionAttributes. There is no per-tool-call ID in that event. So pass your key explicitly: either as a required parameter in the action’s schema, or in sessionAttributes, which your application sets when it calls InvokeAgent and which persist for the session.

typescript
type AgentEvent = {
  messageVersion: string;
  sessionId: string;
  actionGroup: string;
  apiPath: string;
  httpMethod: string;
  parameters?: { name: string; type: string; value: string }[];
  sessionAttributes?: Record<string, string>;
  promptSessionAttributes?: Record<string, string>;
};

function param(e: AgentEvent, name: string): string | undefined {
  return e.parameters?.find((p) => p.name === name)?.value;
}

function respond(e: AgentEvent, status: number, body: unknown) {
  return {
    messageVersion: "1.0",
    response: {
      actionGroup: e.actionGroup,
      apiPath: e.apiPath,
      httpMethod: e.httpMethod,
      httpStatusCode: status,
      responseBody: { "application/json": { body: JSON.stringify(body) } },
    },
    sessionAttributes: e.sessionAttributes,
    promptSessionAttributes: e.promptSessionAttributes,
  };
}

export const handler = async (e: AgentEvent) => {
  const tenantId = e.sessionAttributes?.tenantId;          // set by your app, not the model
  const intentKey = param(e, "idempotencyKey") ?? e.sessionAttributes?.intentKey;
  if (!tenantId) return respond(e, 403, { error: "missing_tenant" });
  if (!intentKey || !/^[\w:.-]{8,36}$/.test(intentKey)) {
    return respond(e, 400, { error: "missing_or_invalid_idempotency_key" });
  }
  if (e.apiPath === "/tickets" && e.httpMethod === "POST") {
    const summary = param(e, "summary") ?? "";
    const result = await createTicketOnce(tenantId, e.sessionId, intentKey, summary);
    return respond(e, 200, result);
  }
  return respond(e, 400, { error: "unsupported_operation" });
};

declare function createTicketOnce(tenant: string, session: string, key: string,
                                  summary: string): Promise<{ ticketId: string; replayed: boolean }>;

Take the tenant from sessionAttributes that your application set, never from a parameter the model fills in. The model can be talked into putting anything into a parameter; it can’t change attributes your backend controls. The Lambda function also needs a resource-based policy that allows Bedrock to invoke it.

Step 5: Compensate with explicit, idempotent tools

Sometimes step two of a multi-step action fails after step one succeeded: the ticket was created but linking it to the PR failed. Don’t “fix” this by running step one again with a new key. Expose an explicit compensating tool, such as cancel_ticket, that is itself idempotent: a conditional update from open to cancelled that succeeds once and replays afterwards. Record compensations in the same ledger so you can audit what the agent did and undid.

Permissions, limits and TTL

There is no separate IAM action for transactions. Each action inside TransactWriteItems is authorized by the permission for the underlying operation (dynamodb:PutItem, dynamodb:UpdateItem, dynamodb:DeleteItem), plus dynamodb:ConditionCheckItem for ConditionCheck actions. To allow writes only through transactions, use the dynamodb:EnclosingOperation condition key:

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ToolLedgerWritesOnlyInTransactions",
      "Effect": "Allow",
      "Action": [
        "dynamodb:PutItem",
        "dynamodb:UpdateItem",
        "dynamodb:ConditionCheckItem"
      ],
      "Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/AgentRuntime",
      "Condition": {
        "ForAnyValue:StringEquals": {
          "dynamodb:EnclosingOperation": ["TransactWriteItems"]
        }
      }
    },
    {
      "Sid": "ReadLedger",
      "Effect": "Allow",
      "Action": ["dynamodb:GetItem", "dynamodb:Query"],
      "Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/AgentRuntime"
    }
  ]
}

The finalize call in Step 3 is a plain UpdateItem, so if you use the transactions-only condition, run finalize as a one-action transaction or grant it a separate statement.

Keep ledger rows small (store large outputs such as diffs in S3 and keep the object key in the row) so you stay well under the 4 MB transaction limit. A single quota item per tenant is also a hot key under bursty agents; if you see TransactionConflict cancellations or throttling on it, split the quota by time window or shard it.

If you set a TTL on ledger rows, remember that DynamoDB deletes expired items at some point after expiry, typically within a few days, not at the expiry second. An expired row still exists until it’s deleted, so a plain attribute_not_exists(pk) keeps blocking the key. If expiry should free the key, write the condition as attribute_not_exists(pk) OR expiresAt < :now, and filter expired items out of queries.

Worked example: a PR bot under a retry storm

  1. The planner schedules “open a PR for branch fix/date-parser” with key pr-7f3c9a1e and stores it in the Step Functions state.
  2. The tool reserves: quota debited, ledger row pending. It calls GitHub, which creates PR #812, and then the Lambda times out before finalizing.
  3. Step Functions retries with the same key. The reserve transaction is canceled with ConditionalCheckFailed at index 1 and the old row comes back as pending. The tool checks GitHub for an open PR from that branch, finds #812, and finalizes the row with that result.
  4. The planner sees duplicate: true with PR #812 and moves on. One PR, one debit.
  5. A different run has no quota left; the transaction fails at index 0, and the planner reports “quota exhausted” to the user instead of retrying.

Troubleshooting

SymptomLikely causeFix
ValidationException: attribute name is a reserved keywordcursor, status, result or ttl used directly in an expressionUse ExpressionAttributeNames placeholders such as #cursor
Duplicate effects after retriesKey generated inside the tool, or ledger written without a conditionCreate the key before the first attempt; condition the ledger put
Quota debited twiceDebit and ledger written separatelyPut both in one TransactWriteItems
Frequent TransactionConflict cancellationsMany concurrent transactions on the same quota or session itemShard hot items; update session metadata less often
IdempotentParameterMismatchSame ClientRequestToken reused with different parameters within 10 minutesDerive the token from the exact action, or omit it for changed requests
Keys stay blocked after TTL expiryExpired items aren’t deleted immediatelyAdd OR expiresAt < :now to the condition
Tenants slowly lose budgetCrashed reservations never releasedRun a sweeper that fails stale pending rows and refunds in one transaction

Production checklist

  • ☐ Every mutating tool requires a stable idempotency key created before the first attempt
  • ☐ Quota debit, ledger row and session update committed in one transaction
  • ☐ ConditionalCheckFailed on the ledger row handled as a replay, with the stored result returned
  • ☐ Reserve, act, finalize for irreversible tools, plus a sweeper for stale reservations
  • ☐ Tenant identity taken from application-controlled session attributes, not model parameters
  • ☐ Reserved words aliased in every expression
  • ☐ IAM limited to the table, with the underlying item actions and ConditionCheckItem
  • ☐ Metrics for duplicates, quota exhaustion and transaction conflicts per tenant
  • ☐ Retry-storm test in staging: replay the same call many times and assert one effect

FAQ

Is ClientRequestToken enough on its own?

No. It only covers identical calls within 10 minutes of the first one finishing. Orchestrator retries and replays can come later, so the conditional ledger row is the real guarantee.

Do I need a transaction if a tool only writes one item?

No. A single conditional PutItem is already atomic. Use a transaction when the effect, the quota and the session state must change together.

Can the transaction span tables?

Yes, as long as the tables are in the same AWS account and Region, and the total stays within 100 items and 4 MB.

How long should ledger rows live?

At least as long as any retry or replay could arrive, including manual re-runs of failed workflows. Many teams keep them for days or weeks and archive them for audit.

Official documentation

Related on CheatCoders: Bedrock Converse tool use and strict tool contracts · Parameter Store config for coding agents

Last updated on October 10, 2026

Watch: 100 Days of AI on YouTube

Short videos from CheatCoders, one AI engineering topic per day.

Open the playlist on YouTube · CheatCoders on YouTube

Deep-dive PDF

Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.

Already subscribed? or open the subscribe page.


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a comment

No account needed. Name and email are optional.