Home / Articles / Coordinating LLM Tool Calls in Node.js with Promise.withResolvers()

This article is published in English.

Coordinating LLM Tool Calls in Node.js with Promise.withResolvers()

See how Promise.withResolvers() untangles tool-call orchestration in a Node.js Lambda calling Claude on Bedrock, plus the timeouts, retries and limits it does not cover.

3047 words

Once a language model can call tools, your application must pause the conversation while a database query or API call runs, then resume with the result. This guide shows how Promise.withResolvers() expresses that pause-and-resume more clearly than hand-rolled promise constructors, walks through a simplified Claude tool loop on AWS Lambda and Amazon Bedrock, and lists the safeguards the API does not provide for you.

Why tool calling turns into an orchestration problem

A tool-using request passes through several asynchronous hops before the user sees an answer:

User
 ↓
Claude
 ↓
Tool call
 ↓
External API / Database
 ↓
Tool result
 ↓
Claude
 ↓
Final response

One part of the program waits while another does the work, then the original flow continues with the result. Traditionally that means nested Promise constructors and resolve/reject functions captured by hand and passed around. Modern runtimes, including current Node.js, offer a cleaner primitive:

Promise.withResolvers()

What Promise.withResolvers() returns

The classic constructor only gives you the settlement functions inside the executor callback:

const promise = new Promise((resolve, reject) => {
  // asynchronous work
});

Settling it from elsewhere means smuggling resolve and reject out of the executor. Promise.withResolvers() hands you all three pieces at once:

const {
  promise,
  resolve,
  reject
} = Promise.withResolvers();

Each value has a single job. The first is what callers wait on:

promise → the promise you await

The other two settle it, with a value or with an error:

resolve → completes the promise successfullyreject → completes the promise with an error

It shines when the code producing a result is separate from the code awaiting it, such as an event handler firing at an unpredictable moment.

Where the classic constructor gets awkward

A basic tool invocation wrapped in a constructor looks harmless:

function callTool(request) {
  return new Promise((resolve, reject) => {
    executeTool(request)
      .then(resolve)
      .catch(reject);
  });
}

Nothing is wrong with it; it is even redundant, since executeTool already returns a promise. Real agent loops, however, juggle much more:

  • streaming model output
  • detecting when the model asks for a tool
  • running the tool
  • database and API calls
  • retries
  • timeouts
  • error handling
  • several independent callbacks

Soon resolve and reject are threaded through several layers, as in this nested version:

function runAgent(request) {
  return new Promise((resolve, reject) => {
invokeModel(request)
      .then(response => {
        executeTool(response)
          .then(result => {
            resolve(result);
          })
          .catch(reject);
      })
      .catch(reject);
  });
}

It works, but tracing success and failure means reading every level. With withResolvers(), the promise and its settlement functions come from one statement and can be used independently:

const {
  promise,
  resolve,
  reject
} = Promise.withResolvers();

Here is a small example where a function fetches a user and settles the externally created promise, while the caller simply awaits it:

const {
  promise,
  resolve,
  reject
} = Promise.withResolvers();
async function fetchUser(id) {
  try {
    const user = await db.getUser(id);
    resolve(user);
  } catch (error) {
    reject(error);
  }
}
fetchUser("U123");
const user = await promise;

In a case this simple, returning the user from fetchUser() would be just as clear; the point is the shape. withResolvers() does not make anything faster. It gives you a cleaner way to express coordination when the place that creates a promise and the place that settles it are not the same.

How this maps onto an agent loop

Suppose a user asks what is new with one of the company's internal products. To answer, Claude may first request a search tool:

Claude
  ↓
Function call
  ↓
searchKnowledgeBase()
  ↓
Database/API
  ↓
Tool result
  ↓
Claude
  ↓
Final response

The code must wait for that result before continuing, and an externally settled promise fits that waiting point naturally.

A simplified Claude tool loop on Lambda

The example below uses Node.js 22, TypeScript, AWS Lambda, Amazon Bedrock and Claude, with Promise.withResolvers() at the centre. The request flow is:

HTTP Request
     ↓
AWS Lambda
     ↓
Claude via Bedrock
     ↓
Claude requests tool
     ↓
Lambda executes tool
     ↓
Tool result
     ↓
Claude
     ↓
Final response

Treat the code as a sketch of the control flow, not a drop-in Bedrock integration; the notes point out where production code must differ.

Step 1: install the Bedrock runtime client

The AWS SDK package for Bedrock Runtime provides the client and the command classes:

npm install @aws-sdk/client-bedrock-runtime

Step 2: import the client and create it

Import the client, the invoke command and the service exception type used for error handling:

import {
  BedrockRuntimeClient,
  InvokeModelCommand,
  BedrockRuntimeServiceException,
} from "@aws-sdk/client-bedrock-runtime";

Then instantiate the client in the region where you have model access:

const client = new BedrockRuntimeClient({
  region: "us-east-1",
});

Step 3: create the resolver inside the handler

Within the Lambda handler, create a promise dedicated to the tool result:

const {
  promise: toolPromise,
  resolve,
  reject
} = Promise.withResolvers();

That gives the handler three handles with clearly separated roles:

toolPromise → waits for the tool result
resolve() → supplies the tool result
reject() → reports a tool failure

Some other callback will eventually settle toolPromise. Create it inside the handler, not at module scope: Lambda reuses warm execution environments, and a module-level promise that is already settled would leak one request's result into the next.

Step 4: describe the request and the tool

The request carries the user's message and a declaration of the searchKnowledgeBase tool, including a JSON Schema for its single query argument:

const prompt = JSON.stringify({
  messages: [
    {
      role: "user",
      content: event.body ?? "Tell me a story."
    }
  ],
toolConfig: {
    tools: [
      {
        name: "searchKnowledgeBase",
        description:
          "Searches the company's knowledge base.",
        inputSchema: {
          type: "object",
          properties: {
            query: {
              type: "string"
            }
          },
          required: ["query"]
        }
      }
    ]
  },
  stream: true
});

The tool definition tells Claude it may request this function when it needs outside information:

searchKnowledgeBase

Check the payload format against the current Bedrock documentation before using it. With InvokeModel, Anthropic models expect the Anthropic Messages format, which includes an anthropic_version field and max_tokens, and declares tools in a tools array with input_schema. The toolConfig shape shown here belongs to Bedrock's separate Converse API, so pick one API and follow its schema.

Step 5: invoke the model

Wrap the payload in a command with a model ID and JSON content type:

const command = new InvokeModelCommand({
  modelId: "your-model-id",
contentType: "application/json",
  accept: "application/json",
  body: Buffer.from(prompt),
});

Send it and turn a failed call into a 502 response, using the Bedrock exception's message when available:

let modelStream;
try {
  const response = await client.send(command);
  modelStream =
    response.body as NodeJS.ReadableStream;
} catch (error) {
  const message =
    (error as BedrockRuntimeServiceException).message
    ?? "Unknown error";
  return {
    statusCode: 502,
    body: JSON.stringify({
      error: `Bedrock call failed: ${message}`
    })
  };
}

For streamed output, Bedrock has dedicated operations (InvokeModelWithResponseStreamCommand, or ConverseStream for the Converse API); plain InvokeModelCommand returns the whole body at once. The next step assumes a streaming variant.

Step 6: detect the tool request

The handler inspects incoming chunks to see whether Claude has asked for the tool. In this simplified version it looks for the tool name in the raw text, extracts the argument with a regular expression and runs the tool:

modelStream.on("data", async (chunk) => {
const text = chunk.toString();
  if (
    text.includes(
      `"name":"searchKnowledgeBase"`
    )
  ) {
    const match =
      /"arguments":\s*"([^"]+)"/
        .exec(text);
    const query =
      match?.[1] ?? "default query";
    mockSearchKnowledgeBase(query)
      .then(resolve)
      .catch(reject);
  }
});

The line to focus on connects the tool's own promise directly to the resolver created in step 3:

mockSearchKnowledgeBase(query)
  .then(resolve)
  .catch(reject);

No extra wrapper promise is needed to expose the result, because the settlement functions already exist. Matching strings in raw chunks is fragile, however: a tool call can be split across chunks, and the argument format will not match a regex like this reliably. Real code should parse the structured stream events and accumulate the tool input until the block is complete. You also need to handle the case where the model finishes without requesting the tool at all; otherwise toolPromise never settles.

Step 7: wait for the tool

With the tool running, the handler awaits the promise and returns a 500 if the tool failed:

let toolResult;
try {
  toolResult =
    await toolPromise;
} catch (error) {
  return {
    statusCode: 500,
    body: JSON.stringify({
      error: `Tool failed: ${error}`
    })
  };
}

This is the core of the pattern. The waiting code has no idea where the result will come from; it only cares that someone eventually calls one of these:

resolve(toolResult)
reject(error)

Step 8: return the tool result to Claude

Once the tool finishes, the result goes back to the model in a follow-up request. Conceptually it contains the assistant's turn and the tool output:

const followUp = JSON.stringify({
  messages: [
    {
      role: "assistant",
      content: "Calling tool..."
    },
    {
      role: "tool",
      name: "searchKnowledgeBase",
      content: JSON.stringify(toolResult)
    }
  ],
  stream: true
});

Then Bedrock is invoked again with the follow-up payload:

const followUpCommand =
  new InvokeModelCommand({
    modelId: "your-model-id",
    contentType: "application/json",
    accept: "application/json",
    body: Buffer.from(followUp)
  });
const response =
  await client.send(followUpCommand);

Claude can now write its final answer. Again the message shape is illustrative: in the Anthropic Messages format, the assistant turn contains a tool_use content block, and the result is sent in a user message as a tool_result block referencing that block's ID, rather than as a separate tool role.

The loop as a whole

Put together, the architecture looks like this:

                 ┌─────────────┐
                 │    User     │
                 └──────┬──────┘
                        │
                        ▼
                 ┌─────────────┐
                 │   Lambda    │
                 └──────┬──────┘
                        │
                        ▼
                 ┌─────────────┐
                 │   Claude    │
                 │  Bedrock    │
                 └──────┬──────┘
                        │
                  Tool request
                        │
                        ▼
                 ┌─────────────┐
                 │    Tool     │
                 └──────┬──────┘
                        │
                  Tool result
                        │
                        ▼
                 ┌─────────────┐
                 │   Claude    │
                 └──────┬──────┘
                        │
                        ▼
                 ┌─────────────┐
                 │    User     │
                 └─────────────┘

Promise.withResolvers() sits at the hand-off point between tool execution and the continuation of the loop:

Tool starts
    │
    ▼
resolve(result)
    │
    ▼
await toolPromise
    │
    ▼
Continue agent loop

A mock tool for testing

To exercise the flow without a real backend, the knowledge-base search can be mocked with a short delay:

function mockSearchKnowledgeBase(
  query: string
): Promise<{ answer: string }> {
return new Promise((resolve) => {
    setTimeout(() => {
      resolve({
        answer:
          `Results for "${query}" (mocked).`
      });
    }, 300);
  });
}

In production the same function might call any of these:

DynamoDB
OpenSearch
RDS
S3
REST API
Internal service
Vector database
Knowledge base

The only contract that matters is that the tool returns a promise.

Guarding against tools that never finish

External tools can hang or disappear. If a tool never settles, this line waits until Lambda itself times out:

await toolPromise;

A timeout wrapper sets an upper bound by racing the promise against a timer and clearing the timer whichever way the promise settles:

function withTimeout<T>(
  promise: Promise<T>,
  milliseconds: number
): Promise<T> {
return new Promise<T>(
    (resolve, reject) => {
      const timer =
        setTimeout(() => {
          reject(
            new Error(
              `Operation timed out after ${milliseconds}ms`
            )
          );
        }, milliseconds);
      promise.then(
        (value) => {
          clearTimeout(timer);
          resolve(value);
        },
        (error) => {
          clearTimeout(timer);
          reject(error);
        }
      );
    }
  );
}

The tool result is then awaited with a two-second limit:

const toolResult =
  await withTimeout(
    toolPromise,
    2000
  );

The wrapper stops your code from waiting, not the tool itself: the query keeps running unless you also pass it an AbortSignal and cancel it.

Handling Bedrock errors deliberately

Distinguish between kinds of failure. The example maps throttling to a 429, other Bedrock service errors to a 502, and rethrows anything unexpected:

try {
  await client.send(command);
} catch (error) {
  if (
    error instanceof Error &&
    error.name === "ThrottlingException"
  ) {
    return {
      statusCode: 429,
      body: JSON.stringify({
        error:
          "Bedrock request was throttled."
      })
    };
  }
  if (
    error instanceof
    BedrockRuntimeServiceException
  ) {
    return {
      statusCode: 502,
      body: JSON.stringify({
        error:
          `Bedrock error: ${error.message}`
      })
    };
  }
  throw error;
}

Retrying throttled calls with backoff

Throttling is often transient, so it is a reasonable candidate for a retry. This helper tries up to three times, waiting a little longer after each throttled attempt, and rethrows every other error immediately:

async function invokeWithBackoff(
  command: InvokeModelCommand,
  attempts = 3
) {
for (
    let attempt = 0;
    attempt < attempts;
    attempt++
  ) {
    try {
      return await client.send(command);
    } catch (error) {
      if (
        error instanceof Error &&
        error.name === "ThrottlingException"
      ) {
        const delay =
          500 * (attempt + 1);
        await new Promise(
          resolve =>
            setTimeout(resolve, delay)
        );
        continue;
      }
      throw error;
    }
  }
  throw new Error(
    "Exceeded retry attempts."
  );
}

Retry only errors that are safe to retry; retrying a permissions problem just produces three identical failures. The delay grows linearly here, and adding random jitter helps when many invocations are throttled at once.

Supporting runtimes without withResolvers()

In an environment that lacks the method, a small helper provides the same shape. It starts by declaring the generic function:

function createDeferred<T>() {

Inside, it declares the settlement functions with definite-assignment assertions, captures them from a regular constructor and returns all three together:

  let resolve!: (value: T) => void;  let reject!: (reason?: unknown) => void;  const promise =
    new Promise<T>((res, rej) => {      resolve = res;
      reject = rej;    });  return {
    promise,
    resolve,
    reject
  };
}

Usage is identical to the native API:

const {
  promise,
  resolve,
  reject
} = createDeferred<Result>();

When the runtime supports Promise.withResolvers() natively, prefer it and drop the helper.

What withResolvers() does not solve

The method simplifies how a promise is created and makes its settlement functions available outside the executor. It does nothing about:

  • race conditions
  • multiple concurrent tool calls
  • cancellation
  • timeouts
  • guarding against settling twice
  • cleaning up resources
  • correct handling of streamed model output
  • tool authorization
  • retry policy

Each of these still has to be designed explicitly. A chain like the one below, with no limit on how many tools the model may call in sequence, is a poor architecture no matter how neatly the promises are written:

Claude
 ↓
Tool A
 ↓
Tool B
 ↓
Tool C
 ↓
Unbounded execution

An agent loop needs hard limits. The blog's guide to bounded agentic loops in TypeScript covers those limits in more depth.

Why the pattern still earns its place

Agent orchestration crosses many asynchronous boundaries between the model's output and its continuation:

Model response
      ↓
Stream event
      ↓
Tool detection
      ↓
Tool execution
      ↓
Database
      ↓
Tool result
      ↓
Model continuation

With nested constructors, that flow is hard to trace. withResolvers() gives it a readable sequence:

Create promise
      ↓
Expose resolver
      ↓
Start asynchronous operation
      ↓
Resolve when result arrives
      ↓
Await result
      ↓
Continue agent loop

Production checklist

Validate tool arguments

Treat model-generated arguments as untrusted input. Check at least:

Types
Required fields
String lengths
Allowed values
Authorization
Business rules

Bound tool execution

Put explicit ceilings on:

Maximum tool calls
Maximum execution time
Maximum model iterations
Maximum response size

Make the loop observable

Record metrics and traces for:

Lambda duration
Bedrock latency
Tool latency
Tool failures
Throttling
Token usage
Agent iterations
Timeouts

Apply least privilege and keep tools narrow

Give the Lambda execution role only the permissions its tools require, and never give the model unrestricted reach into your AWS account or internal systems; expose small, well-defined operations instead.

Choosing between withResolvers() and new Promise()

The constructor keeps resolvers inside the executor, works on every runtime and suits ordinary asynchronous operations, but can force extra nesting in orchestration code. withResolvers() returns the promise and resolvers together and suits cases where settlement happens elsewhere, at the cost of needing a runtime that supports it. Nothing here means every new Promise() should go. When an operation naturally fits this form, keep it:

return new Promise(...)

Reach for withResolvers() when creation and settlement are separated.

Key takeaways

Instead of burying logic inside a constructor like this:

new Promise((resolve, reject) => {
  // deeply nested asynchronous logic
});

you can create the pieces up front:

const {
  promise,
  resolve,
  reject
} = Promise.withResolvers();

and structure the flow as a clear sequence:

Promise creation
       ↓
Asynchronous tool execution
       ↓
resolve / reject
       ↓
Continue agent loop
  • withResolvers() fits the pause-and-resume points of an agent loop, where one callback produces a result and other code waits for it.
  • Create resolvers per request inside the handler, and make sure every path settles the promise, including the one where no tool is called.
  • Follow the exact payload formats of the Bedrock API you choose; the ones shown here are simplified.
  • Timeouts, selective retries, validation, least privilege, observability and iteration limits still have to be added explicitly.

An agent is only as reliable as the asynchronous plumbing around the model, which matters more than clever prompting. Use withResolvers() where it makes that plumbing easier to read, and add the safeguards it cannot provide.