Picture this: you've read all the hype about multi-agent AI and tool-using LLMs, and you're itching to build something real in Node.js. Maybe you want a chatbot that can book meetings, summarize docs, and fetch live data—by coordinating multiple AI agents, each calling tools. But when you wire things up, everything feels… hairier than expected. Agents talk over each other, tool calls vanish into thin air, and you start wondering if everyone else is faking their demos.
I’ve been there. Multi-agent workflows sound magical, but the nitty-gritty of actually getting OpenAI tool calls to play nice with Node.js is full of surprises.
Why Multi-Agent Workflows Are Tricky in Node.js
If you've tried orchestrating several agents—think "Planner" agent, "Math" agent, "Web Search" agent—using OpenAI's functions (the API feature formerly called "tool calls"), you’ll quickly hit a few walls:
- The OpenAI API expects a specific JSON schema for tool calls.
- Node.js is asynchronous, but the agent API expects a certain turn-taking, almost like chat.
- Chaining tool calls and responses feels natural in pseudocode, but in practice, you need to juggle state carefully or you’ll lose track.
I want to save you a few headaches by sharing what tripped me up, and what finally worked.
Example 1: Minimal Tool-Calling Agent in Node.js
First, let’s get a basic OpenAI function-calling agent running in Node. This is the foundation for any multi-agent workflow.
Suppose you want the agent to call a simple math tool:
// mathTool.js
function add({ a, b }) {
return { result: a + b };
}
module.exports = { add };
Now, set up the agent with OpenAI’s API (using openai npm package):
// agent.js
const { OpenAI } = require("openai");
const { add } = require("./mathTool");
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
async function runAgent(userInput) {
// Define the tool schema
const functions = [
{
name: "add",
description: "\"Add two numbers\","
parameters: {
type: "object",
properties: {
a: { type: "number" },
b: { type: "number" }
},
required: ["a", "b"]
}
}
];
// Send user message to OpenAI
const response = await openai.chat.completions.create({
model: "gpt-4o",
messages: [{ role: "user", content: userInput }],
functions,
function_call: "auto" // Let the model decide when to call tools
});
// Check if the model requested a tool call
const msg = response.choices[0].message;
if (msg.function_call) {
// Parse arguments and call our tool
const args = JSON.parse(msg.function_call.arguments);
const toolResult = add(args);
// Send the tool result back for a final answer
const finalResponse = await openai.chat.completions.create({
model: "gpt-4o",
messages: [
{ role: "user", content: userInput },
msg,
{
role: "function",
name: msg.function_call.name,
content: JSON.stringify(toolResult)
}
]
});
return finalResponse.choices[0].message.content;
} else {
// Sometimes the model just answers directly
return msg.content;
}
}
// Usage example
runAgent("What's 3 plus 4?").then(console.log);
Key lines:
- The
functionsarray defines our tool schema for OpenAI. - The agent checks if a tool call is present, then invokes the local function.
- The result is sent back in a
"role": "function"message.
This works for a single tool, but things get weird when you want multiple agents, each with their own tools, passing results around.
Example 2: Chaining Tool Calls Between Agents
Suppose you have two agents: a “Planner” that decides what needs to be done, and an “Executor” that actually calls tools. The tricky part is keeping their state in sync, and making sure only one agent acts at a time.
Here’s a simplified version:
// plannerTool.js
function plan({ goal }) {
// For demo: always returns an "add" action
return { action: "add", args: { a: 2, b: 5 } };
}
module.exports = { plan };
// multiAgent.js
const { OpenAI } = require("openai");
const { plan } = require("./plannerTool");
const { add } = require("./mathTool");
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const functions = [
{
name: "plan",
description: "Plan a sequence of actions to achieve a goal",
parameters: {
type: "object",
properties: {
goal: { type: "string" }
},
required: ["goal"]
}
},
{
name: "add",
description: "Add two numbers",
parameters: {
type: "object",
properties: {
a: { type: "number" },
b: { type: "number" }
},
required: ["a", "b"]
}
}
];
async function multiAgentWorkflow(goal) {
// Start with the Planner agent
let messages = [{ role: "user", content: `Goal: ${goal}` }];
let toolResults = {};
// Step 1: Ask OpenAI how to achieve the goal using the "plan" tool
let response = await openai.chat.completions.create({
model: "gpt-4o",
messages,
functions,
function_call: { name: "plan" }
});
let msg = response.choices[0].message;
let planArgs = JSON.parse(msg.function_call.arguments);
let planResult = plan(planArgs);
// Step 2: Executor agent gets the plan and calls the next tool
messages.push(msg);
messages.push({
role: "function",
name: "plan",
content: JSON.stringify(planResult)
});
// Now, let the model decide which tool to call next
let execResponse = await openai.chat.completions.create({
model: "gpt-4o",
messages,
functions,
function_call: "auto"
});
let execMsg = execResponse.choices[0].message;
if (execMsg.function_call && execMsg.function_call.name === "add") {
const args = JSON.parse(execMsg.function_call.arguments);
const addResult = add(args);
// Feed the result back for a final answer
messages.push(execMsg);
messages.push({
role: "function",
name: "add",
content: JSON.stringify(addResult)
});
let final = await openai.chat.completions.create({
model: "gpt-4o",
messages,
functions
});
return final.choices[0].message.content;
} else {
return execMsg.content;
}
}
// Usage example
multiAgentWorkflow("Add two numbers").then(console.log);
Why this matters:
You have to keep the messages array up-to-date with every tool call and result. If you miss a message, or get the order wrong, the model gets confused—and so does your workflow.
Example 3: Handling Parallel Tool Calls (The Gotcha)
Here’s the kicker nobody tells you: OpenAI's function calling can return multiple tool calls at once (as of mid-2024). But Node.js’s async nature means you have to run and resolve each tool call, then feed the results back—in the right order. If you let two agents run wild, results and context get mixed up fast.
Here’s a snippet that handles multiple tool calls:
// multiToolAgent.js
async function handleToolCalls(messages, functions) {
const response = await openai.chat.completions.create({
model: "gpt-4o",
messages,
functions,
function_call: "auto"
});
const msg = response.choices[0].message;
// Multi-tool calls are in the 'tool_calls' array
if (msg.tool_calls && Array.isArray(msg.tool_calls)) {
const results = await Promise.all(
msg.tool_calls.map(async (toolCall) => {
const args = JSON.parse(toolCall.function.arguments);
let output;
if (toolCall.function.name === "add") {
output = add(args);
} else if (toolCall.function.name === "plan") {
output = plan(args);
}
return {
role: "function",
name: toolCall.function.name,
content: JSON.stringify(output),
tool_call_id: toolCall.id // Tie back to the call
};
})
);
// Feed all results back
const newMessages = [
...messages,
msg,
...results
];
// Continue the conversation (or return, as needed)
const final = await openai.chat.completions.create({
model: "gpt-4o",
messages: newMessages,
functions
});
return final.choices[0].message.content;
} else if (msg.content) {
return msg.content;
}
}
Why this is subtle:
- Each
tool_callgets a uniqueid. If you don’t return results with matching IDs, the model loses track of what happened. - Handling concurrency is easy in Node, but associating responses with the right tool calls is pure bookkeeping.
Common Mistakes
1. Not Including All Messages in Conversation History
I spent a weekend wondering why my agent forgot prior tool results. Turns out, if you drop even a single function message from your messages array, the model will hallucinate, repeat calls, or just give up. Always keep the complete, ordered chat history—including all tool calls and their results.
2. Forgetting to Parse and Validate Function Call Arguments
Even though OpenAI’s functions are typed, the arguments come in as JSON strings. If you don’t parse (and validate!) them, you’ll hit runtime errors or, worse, send garbage to your tools. Always guard against missing or malformed args.
3. Assuming Tool Calls Are Always Sequential
The docs show one tool call at a time, but in reality, the model can request multiple parallel calls. If your workflow or code isn’t ready for that, things break in weird ways. Plan for multiple tool calls and tie results back with IDs.
Key Takeaways
- Keep all messages: Never drop tool calls or function results from your conversation history, or your agents will get confused.
- Expect multiple tool calls: The OpenAI API can return several calls at once, even if your code expects one.
- Validate everything: Parse and check function call arguments before passing them to your tools.
- Manage state explicitly: Node.js’s async nature means you have to track agent and tool state yourself.
- There’s no “one true way”: Multi-agent orchestration involves trade-offs—simplicity vs flexibility, speed vs maintainability.
Wiring up multi-agent workflows in Node.js with OpenAI’s function calling is full of surprises, but once you get the hang of the patterns, it’s incredibly powerful. I hope these gotchas save you a few late nights—happy building!
If you found this helpful, check out more programming tutorials on our blog. We cover Python, JavaScript, Java, Data Science, and more.
Top comments (0)