Imagine Claude not just answering questions but actually reaching out to your own services—creating a seamless AI‑powered workflow. In just a few lines of TypeScript you can expose a Bedrock model, define a tool, and watch the model invoke it in real time.
Understanding Claude’s Tool Use Contract
Why this matters – A large language model (LLM) like Claude can generate plain text, but many real‑world tasks need it to talk to other systems: look up a user, write a file, trigger a deployment, etc. Claude’s tool use feature is the bridge that turns a chat‑style model into a programmable assistant. Think of it like giving the model a set of remote‑control buttons; when the model presses a button, your code runs and sends the result back.
The contract in plain language
-
Tool definition – a JSON schema that tells Claude what arguments a function expects (e.g., an
idstring). -
tool_choice – a request field that says “let Claude decide if it needs a tool (
auto) or force a specific one (any).” -
tool_use block – the exact shape Claude sends when it wants to call a tool:
{type: "tool_use", name: "...", input: {...}}. - tool_result block – what you send back containing the function’s output so Claude can continue the conversation.
If any of these pieces are missing or malformed, Claude falls back to plain text and you never see a tool_use block. That is the most common gotcha for newcomers.
In plain English: Claude only reaches for your API when you give it a clear “button” (tool) and tell it it’s allowed to press it (
tool_choice: "auto"). Missing the button or the permission means Claude just talks.
Analogy
Picture a kitchen robot that can stir, chop, or fetch ingredients. The recipe (your prompt) lists the possible actions (tool definitions). If you forget to list “fetch” or you never give the robot permission to leave the counter, it will simply describe the dish instead of actually bringing the carrots.
Setting Up Bedrock Access in Node.js
Why start here – Before Claude can call a tool, your Node.js program must be able to talk to Amazon Bedrock, the service that hosts Claude. Getting the client right avoids the “rate limit” and “streaming parse” pitfalls that often trip up teams.
Install the SDKs
npm install @aws-sdk/client-bedrock-runtime @aws-sdk/client-api-gateway axios
-
@aws-sdk/client-bedrock-runtime– the official JavaScript client for Bedrock’s inference endpoint. -
@aws-sdk/client-api-gateway– lets you query the configuration of your API Gateway if you need it (e.g., to verify stage settings). -
axios– a tiny HTTP library used later to call the user‑profile endpoint.
Create a Bedrock client
// src/bedrock.ts
import {
BedrockRuntimeClient,
InvokeModelCommand,
} from "@aws-sdk/client-bedrock-runtime";
// The client automatically picks up AWS credentials from the environment
// (e.g., ~/.aws/credentials, ECS task role, or Lambda execution role).
const bedrock = new BedrockRuntimeClient({
region: "us-east-1", // Choose the region where Claude is enabled
// You can add a custom retry strategy here if you hit per‑minute token limits.
});
export { bedrock, InvokeModelCommand };
Gotcha #1 – Token limits are per minute, not per request
Bedrock caps the number of tokens you can send across all requests each minute. If your app spikes, you may see ThrottlingException. A simple mitigation is to add a short delay (e.g., 200 ms) between calls or use a token bucket library.
Tip: Log the
x-amzn-RequestIdheader on every response. It helps you correlate throttling errors with specific requests in CloudWatch.
Gotcha #2 – Streaming responses need manual SSE parsing
Bedrock can stream partial outputs via Server‑Sent Events (SSE). The Node SDK returns a raw Readable stream; you must listen for data events and concatenate the JSON fragments yourself. In this tutorial we use the non‑streaming mode for simplicity, but keep the note in mind when you later add real‑time UI.
Defining a Tool Schema and Prompt
Why define a schema – Claude needs to know what a tool looks like before it can consider using it. The schema is the contract that both Claude and your code agree on. It is similar to an API specification: it tells the model the name of the tool, what arguments it expects, and the types of those arguments.
The tool we will expose
We want Claude to fetch a user profile from an API Gateway endpoint:
-
Tool name –
getUserProfile -
Argument –
userId(a string, e.g.,"abc123")
Prompt with tool definition
// src/prompt.ts
export const systemPrompt = `
You are an assistant that can call external services when needed.
When you need information about a user, use the provided tool.
`;
export const toolDefinition = {
name: "getUserProfile",
description: "Retrieve a user profile from the company directory.",
input_schema: {
type: "object",
properties: {
userId: {
type: "string",
description: "The unique identifier of the user, e.g., 'abc123'.",
},
},
required: ["userId"],
},
};
Full request payload
// src/invokeClaude.ts
import { bedrock, InvokeModelCommand } from "./bedrock";
import { systemPrompt, toolDefinition } from "./prompt";
export async function askClaude(question: string) {
// The model will see the system prompt, the tool definition, and the user query.
const payload = {
// “anthropic.claude-v2” is the model identifier; replace with the version you have access to.
modelId: "anthropic.claude-v2",
// The messages array follows the OpenAI‑compatible chat format.
messages: [
{ role: "system", content: systemPrompt },
{ role: "assistant", tool: toolDefinition, content: "" }, // tool definition block
{ role: "user", content: question },
],
// Tell Claude it can decide on its own whether to call a tool.
tool_choice: "auto",
};
// Bedrock expects a Uint8Array of JSON.
const command = new InvokeModelCommand({
body: Buffer.from(JSON.stringify(payload)),
contentType: "application/json",
accept: "application/json",
// The ARN for Claude in your account (replace <account-id> and region).
// Example: arn:aws:bedrock:us-east-1:123456789012:foundation-model/anthropic.claude-v2
modelId: "arn:aws:bedrock:us-east-1:123456789012:foundation-model/anthropic.claude-v2",
});
const response = await bedrock.send(command);
// The body is a Uint8Array; turn it back into JSON.
const responseBody = JSON.parse(Buffer.from(response.body).toString());
return responseBody;
}
Key takeaway: The
tool_choice: "auto"flag is the permission slip that lets Claude decide to press thegetUserProfilebutton. Without it, Claude will never send atool_useblock.
Analogy
Think of the prompt as a contract negotiation. The system message says “I’m ready to hire you,” the tool definition is the job description, and tool_choice: "auto" is the line in the contract that says “you may take the job when you see fit.” If you leave that line out, the model thinks it’s not allowed to work.
Handling the Tool Use Response and Invoking Your API
Why handle the response – Claude’s reply can be one of two shapes:
- Plain text – no tool needed.
- tool_use block – tells you which tool to run and with what arguments.
Your code must inspect the response, extract the arguments, call the real API, and then send the result back to Claude as a tool_result block.
Detecting a tool_use block
// src/handleResponse.ts
import axios from "axios";
import { askClaude } from "./invokeClaude";
interface ToolUseMessage {
type: "tool_use";
name: string;
input: Record<string, any>;
}
/**
* Main driver – asks Claude a question and reacts if Claude wants a tool.
*/
export async function runConversation(userQuestion: string) {
const raw = await askClaude(userQuestion);
// The response from Bedrock contains a "messages" array.
const messages = raw?.messages ?? [];
// Look for the last assistant message that is a tool_use.
const toolMessage = messages.find(
(m: any) => m.type === "tool_use"
) as ToolUseMessage | undefined;
if (!toolMessage) {
// Claude answered without needing a tool.
console.log("Claude says:", messages.at(-1)?.content);
return;
}
// -----------------------------------------------------------------
// Step 1: Validate the tool name – avoid accidental misuse.
// -----------------------------------------------------------------
if (toolMessage.name !== "getUserProfile") {
throw new Error(`Unexpected tool name: ${toolMessage.name}`);
}
const { userId } = toolMessage.input;
if (typeof userId !== "string") {
throw new Error("userId must be a string");
}
// -----------------------------------------------------------------
// Step 2: Call the real API Gateway endpoint.
// -----------------------------------------------------------------
const apiUrl = `https://api.example.com/user/${encodeURIComponent(userId)}`;
const apiResponse = await axios.get(apiUrl, {
// If your API uses a custom authorizer you would attach headers here.
// Example: headers: { Authorization: `Bearer ${myToken}` }
});
// The profile we care about – assume the API returns { name, email, role }.
const profile = apiResponse.data;
// -----------------------------------------------------------------
// Step 3: Send the result back to Claude.
// -----------------------------------------------------------------
const followUpPayload = {
modelId: "anthropic.claude-v2",
messages: [
// Re‑use the original conversation so Claude has context.
...messages,
{
// This is the tool_result block Claude expects.
role: "assistant",
tool_result: {
tool_use_id: toolMessage.id, // the id Claude gave us
content: JSON.stringify(profile), // Claude prefers a string payload
},
content: "", // content can be empty for tool results
},
],
// No need for another tool_choice – we are just continuing the chat.
tool_choice: "none",
};
const command = new InvokeModelCommand({
body: Buffer.from(JSON.stringify(followUpPayload)),
contentType: "application/json",
accept: "application/json",
modelId: "arn:aws:bedrock:us-east-1:123456789012:foundation-model/anthropic.claude-v2",
});
const finalResponse = await bedrock.send(command);
const finalBody = JSON.parse(Buffer.from(finalResponse.body).toString());
console.log("Claude finishes:", finalBody.messages.at(-1)?.content);
}
Gotcha #3 – tool_use must carry an id field
When Claude sends a tool_use block, it includes an id that you must echo back in tool_result.tool_use_id. Forgetting this field makes Claude think the result belongs to a different request, and the conversation stalls.
Gotcha #4 – API Gateway’s 29‑second timeout
If your downstream Lambda (or whatever backs the /user/{id} route) takes longer than 29 seconds, API Gateway aborts the call. Make sure the profile lookup is fast, or move the heavy work into an asynchronous workflow and return a placeholder.
Helpful tip: Enable API Gateway access logs (
$context.requestId) so you can correlate a failedaxios.getwith a CloudWatch log entry.
Analogy
Imagine Claude as a detective asking you to fetch a file from a locked cabinet. The tool_use message is the detective handing you a key (the userId). You open the cabinet (API call), take out the file (profile JSON), and hand it back in a sealed envelope (tool_result). The detective can then finish the report.
Closing the Loop: Feeding the Result Back to Claude
Why close the loop – Claude cannot finish a task without the data it asked for. By sending the tool_result block you give Claude the missing piece, allowing it to generate a final, user‑friendly answer. The pattern repeats: Claude may ask for more tools, or it may simply respond with text.
Minimal complete example (run from CLI)
// src/index.ts
import { runConversation } from "./handleResponse";
const question = "Can you show me the profile of user abc123?";
runConversation(question).catch((err) => {
console.error("Error during conversation:", err);
});
Run it:
npx ts-node src/index.ts
You should see output similar to:
Claude says: Let me look that up for you...
Claude finishes: Here is the profile you requested:
{
"name": "Alice Johnson",
"email": "alice@example.com",
"role": "Engineer"
}
Gotcha #5 – Streaming vs. non‑streaming
If you later switch to streaming mode to get partial replies, you will need to parse each SSE line and buffer until you encounter a tool_use event. The logic shown above works for the simple request/response mode and avoids the extra parsing complexity.
In plain English: Think of the conversation as a ping‑pong game. Claude throws a question, you catch it, maybe fetch a piece of data, then toss the answer back. The ball (the message) must always contain the right tag (
tool_use/tool_result) so the other side knows how to react.
The Takeaway
Key points to remember
- Claude can call external services, but only when you give it a tool definition and set
tool_choice: "auto". - The Bedrock SDK (
@aws-sdk/client-bedrock-runtime) is the entry point; watch out for per‑minute token limits and manual SSE handling. - A tool_use block must contain the correct
name,input, andid. Missing any part makes Claude fall back to plain text. - Invoke your real API (API Gateway) with a reliable HTTP client; remember the 29‑second timeout and potential CORS misconfigurations.
- Return the result in a tool_result block, echoing the original
tool_use_id. This lets Claude finish the answer. - Treat the whole flow as a conversation where each side respects the same contract—much like a well‑written API specification.
By following these steps, you turn Claude from a static answer engine into an interactive assistant that can pull data from your own services, all with a handful of TypeScript lines. Happy building!
Transparency notice
This article was written with the help of an AI system — Groq (GPT OSS 120B).
Published: 2026-09-30 · Primary focus: Bedrock
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)