Function Calling and Tool Use with Claude and GPT
Deep dive into function calling and tool use with both Claude and GPT APIs, including schema design, the tool loop, and cross-provider patterns.
Function Calling and Tool Use with Claude and GPT
Function calling (OpenAI's term) and tool use (Anthropic's term) let an LLM request the execution of structured operations during a conversation, turning a text-generation model into an agent that can query databases, call APIs, and perform calculations. This is the single most important capability for building production LLM applications, and getting the implementation right across providers saves you from a class of bugs that only surface when your agent is live. This article breaks down the API differences between Claude and GPT, walks through the complete tool use loop, and shows you how to build a unified tool interface that works with both providers.
Prerequisites
- Node.js 18+ installed
- An Anthropic API key (for Claude)
- An OpenAI API key (for GPT)
- Basic familiarity with REST APIs and JSON Schema
- Install the SDKs:
npm install @anthropic-ai/sdk openai
What Function Calling Is and Why It Matters
LLMs generate text. That is all they do. They cannot query your database, check the weather, or read a file. Function calling bridges this gap by letting the model output a structured JSON request that your code intercepts, executes, and feeds back into the conversation.
Without function calling, you are stuck with prompt engineering hacks: "Output JSON in this format and I will parse it." That approach is brittle. The model might wrap your JSON in markdown code fences, add commentary before or after it, or produce malformed output. Function calling solves this at the protocol level. The API returns a typed, validated tool call object that you can deserialize cleanly.
The real power shows up when the model can chain multiple tool calls together. It can look up a customer, check their order history, calculate a discount, and generate a response, all in a single conversation turn. The model decides which tools to call and in what order. Your code just executes them.
Claude Tool Use vs OpenAI Function Calling
Both providers support the same concept, but the API shapes differ meaningfully.
OpenAI calls them "functions" or "tools" (the newer API). You pass a tools array in your request, each with a type: "function" wrapper. The model responds with tool_calls in the assistant message. You send results back as messages with role: "tool".
Anthropic calls them "tools." You pass a tools array at the top level. The model responds with content blocks of type: "tool_use". You send results back as a user message containing content blocks of type: "tool_result".
Here is the structural comparison:
// OpenAI tool definition
var openaiTools = [
{
type: "function",
function: {
name: "get_weather",
description: "Get current weather for a city",
parameters: {
type: "object",
properties: {
city: { type: "string", description: "City name" }
},
required: ["city"]
}
}
}
];
// Anthropic tool definition
var anthropicTools = [
{
name: "get_weather",
description: "Get current weather for a city",
input_schema: {
type: "object",
properties: {
city: { type: "string", description: "City name" }
},
required: ["city"]
}
}
];
The key differences: OpenAI wraps everything in { type: "function", function: { ... } }. Anthropic keeps it flat. OpenAI uses parameters, Anthropic uses input_schema. Both use standard JSON Schema under the hood.
Defining Tool Schemas with JSON Schema
Your tool schemas are the contract between your code and the model. A sloppy schema produces sloppy tool calls. Be precise.
var calculatorSchema = {
type: "object",
properties: {
operation: {
type: "string",
enum: ["add", "subtract", "multiply", "divide"],
description: "The arithmetic operation to perform"
},
a: {
type: "number",
description: "First operand"
},
b: {
type: "number",
description: "Second operand"
}
},
required: ["operation", "a", "b"]
};
Tips for schema design:
- Use enums aggressively. If a parameter has a fixed set of valid values, list them. The model will respect enums almost perfectly.
- Write real descriptions. The description is the most important part. The model reads it to decide when and how to use the tool. "Get weather" is worse than "Get current temperature and conditions for a city. Use this when the user asks about weather, temperature, or whether they need a jacket."
- Mark required fields. Do not leave everything optional unless it truly is. The model will sometimes skip optional fields to save tokens.
- Keep schemas shallow. Deeply nested objects confuse models. Flatten where you can.
The Tool Use Loop
The tool use loop is the core pattern. Every function-calling implementation follows this cycle:
- Send user message + tool definitions to the API
- Model responds with a tool call (or a regular text response)
- Your code executes the tool
- Send the tool result back to the API
- Model responds with another tool call or a final text response
- Repeat until the model stops requesting tools
Here is the loop for Claude:
var Anthropic = require("@anthropic-ai/sdk");
var client = new Anthropic();
function runToolLoop(userMessage, tools, toolExecutor) {
var messages = [{ role: "user", content: userMessage }];
function iterate(callback) {
client.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 4096,
tools: tools,
messages: messages
}, function(err, response) {
if (err) return callback(err);
// Add assistant response to conversation
messages.push({ role: "assistant", content: response.content });
// Check if the model wants to use tools
if (response.stop_reason === "tool_use") {
var toolResults = [];
response.content.forEach(function(block) {
if (block.type === "tool_use") {
var result = toolExecutor(block.name, block.input);
toolResults.push({
type: "tool_result",
tool_use_id: block.id,
content: JSON.stringify(result)
});
}
});
// Send tool results back
messages.push({ role: "user", content: toolResults });
iterate(callback);
} else {
// Model is done, extract text response
var textContent = response.content
.filter(function(block) { return block.type === "text"; })
.map(function(block) { return block.text; })
.join("");
callback(null, textContent);
}
});
}
iterate(function(err, result) {
if (err) {
console.error("Tool loop error:", err.message);
} else {
console.log("Final response:", result);
}
});
}
And the equivalent for OpenAI:
var OpenAI = require("openai");
var openai = new OpenAI();
function runOpenAIToolLoop(userMessage, tools, toolExecutor) {
var messages = [{ role: "user", content: userMessage }];
function iterate(callback) {
openai.chat.completions.create({
model: "gpt-4o",
tools: tools,
messages: messages
}, function(err, response) {
if (err) return callback(err);
var choice = response.choices[0];
messages.push(choice.message);
if (choice.finish_reason === "tool_calls") {
choice.message.tool_calls.forEach(function(toolCall) {
var args = JSON.parse(toolCall.function.arguments);
var result = toolExecutor(toolCall.function.name, args);
messages.push({
role: "tool",
tool_call_id: toolCall.id,
content: JSON.stringify(result)
});
});
iterate(callback);
} else {
callback(null, choice.message.content);
}
});
}
iterate(function(err, result) {
if (err) {
console.error("Tool loop error:", err.message);
} else {
console.log("Final response:", result);
}
});
}
Notice the structural differences: Claude uses stop_reason: "tool_use" and content blocks. OpenAI uses finish_reason: "tool_calls" and a tool_calls array on the message. The tool result goes back as a tool_result content block (Claude) vs a tool role message (OpenAI).
Parallel Tool Calls
Both Claude and GPT can request multiple tool calls in a single response. This is not a special mode you enable; the model just does it when it makes sense.
When the user asks "What is the weather in New York and London?", the model will emit two tool_use blocks (Claude) or two entries in tool_calls (OpenAI) in the same response. You must handle all of them before sending results back.
// Handling parallel tool calls (Claude)
function handleParallelCalls(responseContent, toolExecutor) {
var toolBlocks = responseContent.filter(function(block) {
return block.type === "tool_use";
});
console.log("Model requested " + toolBlocks.length + " tool calls");
var results = toolBlocks.map(function(block) {
var result = toolExecutor(block.name, block.input);
return {
type: "tool_result",
tool_use_id: block.id,
content: JSON.stringify(result)
};
});
return results;
}
A critical mistake I see in production code: executing parallel tool calls sequentially when they could run concurrently. If each tool call hits an external API, you are wasting time. Use Promise.all or a parallel execution library.
function handleParallelAsync(responseContent, toolExecutor, callback) {
var toolBlocks = responseContent.filter(function(block) {
return block.type === "tool_use";
});
var pending = toolBlocks.length;
var results = new Array(pending);
toolBlocks.forEach(function(block, index) {
toolExecutor(block.name, block.input, function(err, result) {
results[index] = {
type: "tool_result",
tool_use_id: block.id,
content: err
? JSON.stringify({ error: err.message })
: JSON.stringify(result)
};
pending--;
if (pending === 0) callback(null, results);
});
});
}
Nested and Chained Tool Calls
The model can chain tool calls across multiple loop iterations. First it calls search_customers to find a user, then uses the customer ID from that result to call get_order_history, then calls calculate_discount with the order total.
You do not need to do anything special to enable this. The tool use loop handles it naturally. Each iteration, the model sees the accumulated conversation (including prior tool results) and decides what to call next. The key insight is that your tool results become part of the conversation context. Return enough information for the model to make its next decision.
// Bad: returning minimal data
function getCustomer(id) {
return { name: "John Doe" }; // Model cannot chain from this
}
// Good: returning data the model needs for chaining
function getCustomer(id) {
return {
id: id,
name: "John Doe",
email: "[email protected]",
tier: "premium",
account_created: "2023-01-15"
};
}
Input Validation Before Executing Tools
Never trust the model's tool inputs blindly. Models hallucinate parameters, send wrong types, and occasionally inject values that look like prompt injection attempts.
function validateToolInput(name, input) {
var errors = [];
if (name === "query_database") {
if (typeof input.table !== "string") {
errors.push("table must be a string");
}
var allowedTables = ["customers", "orders", "products"];
if (allowedTables.indexOf(input.table) === -1) {
errors.push("table must be one of: " + allowedTables.join(", "));
}
if (input.limit && (typeof input.limit !== "number" || input.limit > 100)) {
errors.push("limit must be a number <= 100");
}
if (input.where && typeof input.where !== "string") {
errors.push("where must be a string");
}
// Prevent SQL injection even though we parameterize
if (input.where && /;|DROP|DELETE|UPDATE|INSERT/i.test(input.where)) {
errors.push("where clause contains forbidden keywords");
}
}
return errors;
}
function safeToolExecutor(name, input) {
var validationErrors = validateToolInput(name, input);
if (validationErrors.length > 0) {
return {
error: "Validation failed",
details: validationErrors
};
}
return executeToolUnsafe(name, input);
}
Return validation errors as tool results, not exceptions. The model can read the error and self-correct on the next iteration.
Error Handling When Tools Fail
Tools fail. APIs time out. Databases go down. Your tool executor must handle failures gracefully and return error information the model can use.
function resilientToolExecutor(name, input) {
try {
var handler = toolRegistry[name];
if (!handler) {
return {
error: "Unknown tool: " + name,
available_tools: Object.keys(toolRegistry)
};
}
var result = handler(input);
return { success: true, data: result };
} catch (err) {
console.error("Tool execution failed:", name, err.message);
return {
error: err.message,
tool: name,
suggestion: "The tool encountered an error. You may retry with different parameters or inform the user."
};
}
}
The suggestion field is a subtle but effective trick. The model reads it and adjusts its behavior. Without it, some models will silently retry the same failing call in an infinite loop. With it, the model usually tells the user what happened.
Set a maximum iteration count on your tool loop to prevent runaway chains:
var MAX_ITERATIONS = 10;
function boundedToolLoop(messages, tools, toolExecutor, iteration, callback) {
if (iteration >= MAX_ITERATIONS) {
return callback(new Error("Tool loop exceeded " + MAX_ITERATIONS + " iterations"));
}
// ... rest of loop logic
boundedToolLoop(messages, tools, toolExecutor, iteration + 1, callback);
}
Building a Unified Tool Interface Across Providers
If you support both Claude and GPT, you do not want to maintain two sets of tool definitions. Build a provider-agnostic registry and adapt at the edges.
var toolRegistry = {};
function registerTool(name, description, schema, handler) {
toolRegistry[name] = {
name: name,
description: description,
schema: schema,
handler: handler
};
}
function getToolsForClaude() {
return Object.keys(toolRegistry).map(function(name) {
var tool = toolRegistry[name];
return {
name: tool.name,
description: tool.description,
input_schema: tool.schema
};
});
}
function getToolsForOpenAI() {
return Object.keys(toolRegistry).map(function(name) {
var tool = toolRegistry[name];
return {
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: tool.schema
}
};
});
}
function executeTool(name, input) {
var tool = toolRegistry[name];
if (!tool) throw new Error("Unknown tool: " + name);
return tool.handler(input);
}
This pattern saves you from the most common maintenance headache: adding a tool in one provider format and forgetting to update the other.
Tool Selection Strategies
Do not dump every tool into every request. Models perform worse when given too many tools. I have seen accuracy drop noticeably past 15-20 tools in a single request.
Strategies that work:
- Route by intent. Use a cheap classifier (or even keyword matching) to determine the user's intent, then only include relevant tools.
- Two-pass approach. First call the model with no tools to understand the request. Then call it again with the appropriate tool subset.
- Tool groups. Organize tools into logical groups (database tools, API tools, math tools) and include groups based on context.
var toolGroups = {
weather: ["get_weather", "get_forecast"],
database: ["query_customers", "query_orders", "query_products"],
math: ["calculate", "convert_units"]
};
function selectTools(userMessage) {
var selected = [];
if (/weather|temperature|forecast|rain/i.test(userMessage)) {
selected = selected.concat(toolGroups.weather);
}
if (/customer|order|product|database|look\s*up/i.test(userMessage)) {
selected = selected.concat(toolGroups.database);
}
if (/calculat|math|convert|add|multiply/i.test(userMessage)) {
selected = selected.concat(toolGroups.math);
}
// Always provide at least a base set
if (selected.length === 0) {
selected = Object.keys(toolRegistry);
}
return selected.map(function(name) { return toolRegistry[name]; });
}
Security Considerations
Tool use turns your LLM application into something that can execute code, query databases, and call APIs. Treat it with the same seriousness as any RPC endpoint.
Allowlist, never blocklist. Define exactly which tools are available. Do not start with "all tools" and try to remove dangerous ones.
Sandbox execution. If tools can run arbitrary queries or commands, run them in a sandboxed environment. Docker containers, restricted database users, or separate service accounts.
Rate limit tool calls. A model in a loop can burn through API quotas fast. Limit both the number of iterations and the rate of external API calls.
Audit everything. Log every tool call with its input, output, the user who triggered it, and the conversation ID. You will need this when something goes wrong.
function auditedToolExecutor(name, input, context) {
var startTime = Date.now();
console.log(JSON.stringify({
event: "tool_call_start",
tool: name,
input: input,
user: context.userId,
conversation: context.conversationId,
timestamp: new Date().toISOString()
}));
var result;
var error = null;
try {
result = executeTool(name, input);
} catch (err) {
error = err.message;
result = { error: err.message };
}
console.log(JSON.stringify({
event: "tool_call_end",
tool: name,
duration_ms: Date.now() - startTime,
success: error === null,
error: error,
user: context.userId,
conversation: context.conversationId,
timestamp: new Date().toISOString()
}));
return result;
}
Streaming with Tool Use
Both providers support streaming with tool use, but the implementation gets tricky. During streaming, you receive partial JSON for tool call arguments. You have to buffer and assemble them.
With Claude, you listen for content_block_start events of type tool_use, then accumulate input_json_delta events until content_block_stop:
var Anthropic = require("@anthropic-ai/sdk");
var client = new Anthropic();
function streamWithTools(messages, tools) {
var stream = client.messages.stream({
model: "claude-sonnet-4-20250514",
max_tokens: 4096,
tools: tools,
messages: messages
});
var currentToolInput = "";
var currentToolName = "";
var currentToolId = "";
stream.on("contentBlockStart", function(event) {
if (event.content_block.type === "tool_use") {
currentToolName = event.content_block.name;
currentToolId = event.content_block.id;
currentToolInput = "";
}
});
stream.on("contentBlockDelta", function(event) {
if (event.delta.type === "input_json_delta") {
currentToolInput += event.delta.partial_json;
} else if (event.delta.type === "text_delta") {
process.stdout.write(event.delta.text);
}
});
stream.on("contentBlockStop", function() {
if (currentToolName) {
var input = JSON.parse(currentToolInput);
console.log("\nTool call: " + currentToolName, input);
// Execute tool and continue loop
currentToolName = "";
}
});
stream.on("message", function(message) {
console.log("\nStream complete, stop_reason:", message.stop_reason);
});
}
With OpenAI, tool call arguments arrive as delta.tool_calls[i].function.arguments chunks that you concatenate by index:
var OpenAI = require("openai");
var openai = new OpenAI();
function streamWithToolsOpenAI(messages, tools) {
var toolCallBuffers = {};
openai.chat.completions.create({
model: "gpt-4o",
tools: tools,
messages: messages,
stream: true
}).then(function(stream) {
stream.on("data", function(chunk) {
var delta = chunk.choices[0].delta;
if (delta.content) {
process.stdout.write(delta.content);
}
if (delta.tool_calls) {
delta.tool_calls.forEach(function(tc) {
if (!toolCallBuffers[tc.index]) {
toolCallBuffers[tc.index] = {
id: tc.id,
name: tc.function ? tc.function.name : "",
arguments: ""
};
}
if (tc.function && tc.function.arguments) {
toolCallBuffers[tc.index].arguments += tc.function.arguments;
}
});
}
if (chunk.choices[0].finish_reason === "tool_calls") {
Object.keys(toolCallBuffers).forEach(function(index) {
var buf = toolCallBuffers[index];
var args = JSON.parse(buf.arguments);
console.log("\nTool call:", buf.name, args);
});
}
});
});
}
Complete Working Example
Here is a complete Node.js application with a shared tool registry that works with both Claude and GPT. It includes weather lookup, database query, and calculation tools with the full tool use loop.
// agent.js - Unified tool-calling agent for Claude and GPT
var Anthropic = require("@anthropic-ai/sdk");
var OpenAI = require("openai");
var anthropic = new Anthropic();
var openai = new OpenAI();
// ============================================================
// Tool Registry
// ============================================================
var toolRegistry = {};
function registerTool(name, description, schema, handler) {
toolRegistry[name] = {
name: name,
description: description,
schema: schema,
handler: handler
};
}
// Weather tool
registerTool(
"get_weather",
"Get the current weather for a specified city. Returns temperature in Fahrenheit, conditions, and humidity.",
{
type: "object",
properties: {
city: {
type: "string",
description: "The city name, e.g. 'San Francisco' or 'London'"
},
units: {
type: "string",
enum: ["fahrenheit", "celsius"],
description: "Temperature units. Defaults to fahrenheit."
}
},
required: ["city"]
},
function(input) {
// Simulated weather data - replace with real API call
var weatherData = {
"san francisco": { temp_f: 62, conditions: "Foggy", humidity: 78 },
"new york": { temp_f: 45, conditions: "Cloudy", humidity: 55 },
"london": { temp_f: 50, conditions: "Rainy", humidity: 82 },
"tokyo": { temp_f: 58, conditions: "Clear", humidity: 40 }
};
var cityKey = input.city.toLowerCase();
var data = weatherData[cityKey];
if (!data) {
return { error: "Weather data not available for: " + input.city };
}
var temp = data.temp_f;
if (input.units === "celsius") {
temp = Math.round((temp - 32) * 5 / 9);
}
return {
city: input.city,
temperature: temp,
units: input.units || "fahrenheit",
conditions: data.conditions,
humidity: data.humidity
};
}
);
// Database query tool
registerTool(
"query_database",
"Query the customer database. Supports searching by name, email, or listing recent orders. Use this when the user asks about customers or orders.",
{
type: "object",
properties: {
action: {
type: "string",
enum: ["find_customer", "get_orders", "count_customers"],
description: "The database action to perform"
},
search_term: {
type: "string",
description: "Search term for find_customer (name or email)"
},
customer_id: {
type: "number",
description: "Customer ID for get_orders"
}
},
required: ["action"]
},
function(input) {
// Simulated database
var customers = [
{ id: 1, name: "Alice Chen", email: "[email protected]", tier: "premium" },
{ id: 2, name: "Bob Smith", email: "[email protected]", tier: "standard" },
{ id: 3, name: "Carol Davis", email: "[email protected]", tier: "premium" }
];
var orders = [
{ id: 101, customer_id: 1, total: 249.99, status: "shipped", date: "2025-12-15" },
{ id: 102, customer_id: 1, total: 89.50, status: "delivered", date: "2026-01-03" },
{ id: 103, customer_id: 2, total: 175.00, status: "processing", date: "2026-01-28" }
];
if (input.action === "find_customer") {
var term = (input.search_term || "").toLowerCase();
var matches = customers.filter(function(c) {
return c.name.toLowerCase().indexOf(term) !== -1 ||
c.email.toLowerCase().indexOf(term) !== -1;
});
return { customers: matches, count: matches.length };
}
if (input.action === "get_orders") {
var customerOrders = orders.filter(function(o) {
return o.customer_id === input.customer_id;
});
return { orders: customerOrders, count: customerOrders.length };
}
if (input.action === "count_customers") {
return { total_customers: customers.length };
}
return { error: "Unknown action: " + input.action };
}
);
// Calculator tool
registerTool(
"calculate",
"Perform arithmetic calculations. Use this for any math the user requests, including totals, averages, percentages, and conversions.",
{
type: "object",
properties: {
operation: {
type: "string",
enum: ["add", "subtract", "multiply", "divide", "percentage", "average"],
description: "The math operation to perform"
},
values: {
type: "array",
items: { type: "number" },
description: "The numbers to operate on. For percentage: [value, percentage]. For others: list of numbers."
}
},
required: ["operation", "values"]
},
function(input) {
var vals = input.values;
if (!vals || vals.length === 0) {
return { error: "No values provided" };
}
var result;
switch (input.operation) {
case "add":
result = vals.reduce(function(sum, v) { return sum + v; }, 0);
break;
case "subtract":
result = vals.reduce(function(diff, v, i) { return i === 0 ? v : diff - v; });
break;
case "multiply":
result = vals.reduce(function(prod, v) { return prod * v; }, 1);
break;
case "divide":
if (vals[1] === 0) return { error: "Division by zero" };
result = vals[0] / vals[1];
break;
case "percentage":
result = vals[0] * (vals[1] / 100);
break;
case "average":
result = vals.reduce(function(sum, v) { return sum + v; }, 0) / vals.length;
break;
default:
return { error: "Unknown operation: " + input.operation };
}
return {
operation: input.operation,
values: vals,
result: Math.round(result * 100) / 100
};
}
);
// ============================================================
// Provider Adapters
// ============================================================
function formatToolsForProvider(provider) {
var tools = Object.keys(toolRegistry).map(function(name) {
var tool = toolRegistry[name];
if (provider === "claude") {
return {
name: tool.name,
description: tool.description,
input_schema: tool.schema
};
}
if (provider === "openai") {
return {
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: tool.schema
}
};
}
});
return tools;
}
function executeTool(name, input) {
var tool = toolRegistry[name];
if (!tool) {
return { error: "Unknown tool: " + name };
}
try {
return tool.handler(input);
} catch (err) {
return { error: "Tool execution failed: " + err.message };
}
}
// ============================================================
// Claude Tool Loop
// ============================================================
function runClaudeAgent(userMessage, callback) {
var tools = formatToolsForProvider("claude");
var messages = [{ role: "user", content: userMessage }];
var iterations = 0;
var maxIterations = 10;
function loop() {
iterations++;
if (iterations > maxIterations) {
return callback(new Error("Max iterations exceeded"));
}
anthropic.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 4096,
tools: tools,
messages: messages
}).then(function(response) {
messages.push({ role: "assistant", content: response.content });
if (response.stop_reason === "tool_use") {
var toolResults = [];
response.content.forEach(function(block) {
if (block.type === "tool_use") {
console.log("[Claude] Tool call: " + block.name, JSON.stringify(block.input));
var result = executeTool(block.name, block.input);
console.log("[Claude] Tool result:", JSON.stringify(result));
toolResults.push({
type: "tool_result",
tool_use_id: block.id,
content: JSON.stringify(result)
});
}
});
messages.push({ role: "user", content: toolResults });
loop();
} else {
var text = response.content
.filter(function(b) { return b.type === "text"; })
.map(function(b) { return b.text; })
.join("");
callback(null, text);
}
}).catch(function(err) {
callback(err);
});
}
loop();
}
// ============================================================
// OpenAI Tool Loop
// ============================================================
function runOpenAIAgent(userMessage, callback) {
var tools = formatToolsForProvider("openai");
var messages = [{ role: "user", content: userMessage }];
var iterations = 0;
var maxIterations = 10;
function loop() {
iterations++;
if (iterations > maxIterations) {
return callback(new Error("Max iterations exceeded"));
}
openai.chat.completions.create({
model: "gpt-4o",
tools: tools,
messages: messages
}).then(function(response) {
var choice = response.choices[0];
messages.push(choice.message);
if (choice.finish_reason === "tool_calls") {
choice.message.tool_calls.forEach(function(toolCall) {
var args = JSON.parse(toolCall.function.arguments);
console.log("[OpenAI] Tool call: " + toolCall.function.name, JSON.stringify(args));
var result = executeTool(toolCall.function.name, args);
console.log("[OpenAI] Tool result:", JSON.stringify(result));
messages.push({
role: "tool",
tool_call_id: toolCall.id,
content: JSON.stringify(result)
});
});
loop();
} else {
callback(null, choice.message.content);
}
}).catch(function(err) {
callback(err);
});
}
loop();
}
// ============================================================
// Unified Interface
// ============================================================
function runAgent(provider, userMessage, callback) {
console.log("\n=== Running " + provider + " agent ===");
console.log("User: " + userMessage + "\n");
if (provider === "claude") {
runClaudeAgent(userMessage, callback);
} else if (provider === "openai") {
runOpenAIAgent(userMessage, callback);
} else {
callback(new Error("Unknown provider: " + provider));
}
}
// ============================================================
// Main
// ============================================================
var provider = process.argv[2] || "claude";
var query = process.argv[3] || "Look up Alice in the customer database, get her orders, and calculate the total.";
runAgent(provider, query, function(err, response) {
if (err) {
console.error("Error:", err.message);
process.exit(1);
}
console.log("\n=== Final Response ===");
console.log(response);
});
Run it:
# Using Claude
node agent.js claude "What is the weather in San Francisco and Tokyo?"
# Using OpenAI
node agent.js openai "Look up Alice, get her orders, and calculate the total."
Expected output for the chained query:
=== Running claude agent ===
User: Look up Alice in the customer database, get her orders, and calculate the total.
[Claude] Tool call: query_database {"action":"find_customer","search_term":"Alice"}
[Claude] Tool result: {"customers":[{"id":1,"name":"Alice Chen","email":"[email protected]","tier":"premium"}],"count":1}
[Claude] Tool call: query_database {"action":"get_orders","customer_id":1}
[Claude] Tool result: {"orders":[{"id":101,"customer_id":1,"total":249.99,"status":"shipped","date":"2025-12-15"},{"id":102,"customer_id":1,"total":89.50,"status":"delivered","date":"2026-01-03"}],"count":2}
[Claude] Tool call: calculate {"operation":"add","values":[249.99,89.50]}
[Claude] Tool result: {"operation":"add","values":[249.99,89.5],"result":339.49}
=== Final Response ===
Alice Chen ([email protected]) is a premium customer with 2 orders totaling $339.49.
Common Issues and Troubleshooting
1. Tool call arguments are not valid JSON
SyntaxError: Unexpected token } in JSON at position 42
This happens when OpenAI sends malformed JSON in tool_calls[i].function.arguments. It is rare with GPT-4o but more common with older models. Wrap your JSON.parse in a try-catch and return an error tool result so the model can retry:
try {
var args = JSON.parse(toolCall.function.arguments);
} catch (parseErr) {
messages.push({
role: "tool",
tool_call_id: toolCall.id,
content: JSON.stringify({ error: "Invalid JSON in arguments: " + parseErr.message })
});
// Continue loop - model will retry
}
2. Missing tool_use_id in Claude tool results
Error: messages.1.content.0: tool_use_id is required for tool_result blocks
Every tool_result block must include the tool_use_id from the corresponding tool_use block. If you forget to copy it or if you reorder results, the API rejects the request. Always map results one-to-one with the tool calls.
3. OpenAI finish_reason is "stop" instead of "tool_calls"
// You expected tool calls but got:
{ finish_reason: "stop", message: { content: "I don't have access to..." } }
This means the model decided not to use tools. Common causes: the tool descriptions do not match the user's request, you hit the token limit before the model could emit tool calls, or the model decided it could answer without tools. Check your tool descriptions and ensure they clearly state when the tool should be used.
4. Infinite tool loop
The model keeps calling the same tool with the same arguments, getting the same error back, and retrying forever. This usually happens when the error message is vague ("something went wrong"). Return specific, actionable error messages and set a hard iteration limit:
if (iterations > maxIterations) {
// Force a response without tools
messages.push({
role: "user",
content: "Please provide your best answer with the information available. Do not call any more tools."
});
// Make one final call without tools
runWithoutTools(messages, callback);
}
5. Claude returns both text and tool_use in the same response
This is expected behavior. Claude often includes explanatory text alongside tool calls. Do not assume the response is either text-only or tools-only. Always check stop_reason to determine if you need to execute tools, and process both text and tool_use content blocks.
Best Practices
Write detailed tool descriptions. The description is the most important field. Tell the model exactly when to use the tool, what it returns, and what its limitations are. Treat it like API documentation for an LLM consumer.
Return structured errors, not exceptions. When a tool fails, return a JSON object with an
errorfield as the tool result. The model can read this and either retry with different parameters or inform the user. Throwing an exception breaks the loop.Set hard iteration limits. Never let the tool loop run unbounded. Ten iterations covers the vast majority of legitimate multi-step workflows. Anything beyond that is likely a bug or adversarial input.
Log every tool call. In production, you need an audit trail. Log the tool name, inputs, outputs, duration, and conversation context. This is non-negotiable for debugging and compliance.
Test with adversarial inputs. Ask the model to "delete all customers" or "run rm -rf /". Make sure your validation catches these before they reach your tool handlers. Do not rely on the model's safety training as your only defense.
Keep tool schemas tight. Use
enumfor fixed values,requiredfor mandatory fields, anddescriptionfor everything. AvoidadditionalProperties: truesince models will invent fields you do not handle.Minimize the tool count per request. Aim for 5-10 tools maximum in a single request. If you have 50 tools, implement a routing layer that selects the relevant subset based on the conversation context.
Return only what the model needs. Large tool results consume context window tokens. If your database query returns 500 rows, summarize or paginate. Return the fields the model actually needs, not the entire row.
Use the same tool names across providers. Your unified registry should use identical tool names for Claude and OpenAI. This makes switching providers a one-line change and keeps your tool executor provider-agnostic.