Implement an LLM tool-calling loop in Jitterbit Studio
Introduction
Single-round function calling (covered in Route LLM responses to Studio operations using function calling) handles one tool selection per request: the model picks a function, a Studio operation runs it, and the interaction ends. Many agentic workflows require more than one tool call to answer a single user request: the model may need to query a CRM record, then fetch account details, then draft a summary using both results.
A tool-calling loop handles this by returning each tool result to the LLM as a tool role message and calling the model again with the extended conversation. The loop repeats until the model produces a plain-text response with no tool calls, or a maximum iteration limit is reached.
This guide builds on:
- Route LLM responses to Studio operations using function calling for tool schema definition and the single-round dispatch pattern. Read that guide first.
- Build a multi-turn LLM chat with conversation history if you are combining the tool-calling loop with persistent conversation memory across user turns.
Design pattern
The loop adds two steps to the single-round function calling pattern: appending the tool result to the messages array, and calling the LLM again. The loop repeats as long as the model response includes a tool_calls array.
Build initial request
(base messages + tools)"] --> B["HTTP v2
LLM call"] B --> C{"tool_calls
in response?"} C -->|Yes| D["Script
Extract function
name and ID"] D --> E["Dispatcher
Run tool
operation"] E --> F["Script
Append tool result
rebuild request"] F --> B C -->|No| G["Script
Extract final
response"]
Five global variables carry state across iterations:
| Variable | Purpose |
|---|---|
InAndOut |
The current LLM request body (updated before each call). |
non_tool_messages_json |
The base messages (system prompt and user message) as a JSON array string. Remains constant across all iterations. |
tools_messages |
The accumulated tool exchange messages from all prior rounds as a JSON array string. Grows with each iteration. |
tools_resp |
The most recent LLM response that contained a tool_calls selection. Used to extract the assistant message for accumulation. |
call_llm_again |
Set to true when a tool call is in progress; set to false when the model returns a final response. |
Part 1: Initialize the loop variables
Before the first LLM call, build the base messages and the initial request body, and reset all loop state variables. Add a script step at the start of the operation:
// Escape and build the base messages as a JSON array string
systemMsg = "{\"role\":\"system\",\"content\":\""
+ Replace($systemPrompt, "\"", "\\\"") + "\"}";
userMsg = "{\"role\":\"user\",\"content\":\""
+ Replace($userMessage, "\"", "\\\"") + "\"}";
$non_tool_messages_json = "[" + systemMsg + "," + userMsg + "]";
// Build the full initial request body
$InAndOut = "{\"model\":\"gpt-4o\","
+ "\"messages\":[" + systemMsg + "," + userMsg + "],"
+ "\"tools\":" + $toolsJson + ","
+ "\"tool_choice\":\"auto\"}";
// Initialize loop state
$tools_messages = "";
$tools_resp = "";
$call_llm_again = false;
$loop_count = 0;
toolsJson is a project variable that holds the serialized tool schema array. For the tool definition format and how to construct the schemas, see Route LLM responses to Studio operations using function calling.
Note
Save the base messages in non_tool_messages_json before the loop starts. Each iteration combines these base messages with the accumulated tool exchanges to rebuild the full messages array. Do not update non_tool_messages_json inside the loop.
Part 2: Parse the tool call response
Chain a LLM Call operation (HTTP v2 POST to the OpenAI Chat Completions endpoint, with InAndOut as the request body). For connection and authentication setup, see Call a REST API using the HTTP v2 connector.
On success, add a script step to read the response and set the loop control variables:
// Check whether the model selected a tool
toolCallsVal = GetJSONString($jitterbit.response, "/choices/0/message/tool_calls");
hasToolCalls = (toolCallsVal != "null" && Length(toolCallsVal) > 2);
If(hasToolCalls,
$tool_call_id = TrimChars(GetJSONString($jitterbit.response,
"/choices/0/message/tool_calls/0/id"), "\"");
$function_name = TrimChars(GetJSONString($jitterbit.response,
"/choices/0/message/tool_calls/0/function/name"), "\"");
$function_arguments = GetJSONString($jitterbit.response,
"/choices/0/message/tool_calls/0/function/arguments");
$tools_resp = $jitterbit.response;
$call_llm_again = true;
,
$call_llm_again = false;
$final_response = TrimChars(GetJSONString($jitterbit.response,
"/choices/0/message/content"), "\"");
);
tool_call_id is a unique identifier that the API assigns to each tool call. It must be returned in the tool result message in Part 4, exactly as received, or the API will reject the request.
Note
tool_calls is absent from the response when the model returns plain text. The GetJSONString call returns "null" in that case, and the length check > 2 correctly distinguishes a populated array (which is at minimum [...], length 3 or more) from an absent or empty value.
Handling multiple tool calls
The script above reads tool_calls/0, the first tool call in the response. An LLM can return several tool calls in a single response (parallel tool calls), and any calls after the first are ignored. To handle every call, do one of the following:
- Disable parallel tool calls in the LLM request so the model returns at most one call per response. For the OpenAI and Azure OpenAI Chat Completions APIs, set
parallel_tool_callstofalsein the request body built in Part 1. - Iterate over the
tool_callsarray, dispatching the tool and appending an assistant/toolmessage pair for each entry before the next LLM call. The LLM API requires a matchingtoolresult for everytool_call_idin the assistant message.
Part 3: Dispatch to the tool operation
Use a Case statement to dispatch to the correct tool operation based on function_name, following the same pattern as in Route LLM responses to Studio operations using function calling. Each target operation executes the requested function and sets function_resp to the result string.
function_arguments contains the model's parameter selections as a JSON string. Parse it using JSONParser within each target operation to extract individual argument values.
Part 4: Append the tool result and rebuild the request
After the tool operation sets function_resp, build the updated messages array for the next LLM call. This step requires complex JSON manipulation: parsing the accumulated messages, appending new entries, and re-serializing the result. Implement it as a JavaScript script step:
var tool_resp = JSON.parse($tools_resp);
var prev_tool_msgs = $tools_messages ? JSON.parse($tools_messages) : [];
var base_msgs = JSON.parse($non_tool_messages_json);
// The assistant message that selected the tool (must precede the tool result)
var assistant_msg = tool_resp.choices[0].message;
// The tool result returned by the Studio operation
var tool_result_msg = {
"role": "tool",
"tool_call_id": $tool_call_id,
"content": String($function_resp)
};
// Accumulate all tool exchanges: assistant selection followed by tool result
prev_tool_msgs.push(assistant_msg);
prev_tool_msgs.push(tool_result_msg);
// Rebuild the full request with base messages + all accumulated tool exchanges
var req = JSON.parse($InAndOut);
req["messages"] = base_msgs.concat(prev_tool_msgs);
$tools_messages = JSON.stringify(prev_tool_msgs);
$InAndOut = JSON.stringify(req);
To use JavaScript in a script step, set the script language to JavaScript in the Script editor before adding content.
Note
The assistant message (tool_resp.choices[0].message) must appear immediately before its matching tool result message in the array. The OpenAI API requires the tool_calls selection and the corresponding tool result to be adjacent and in the same order as the calls were made. A mismatched or missing tool_call_id causes a 400 error.
Tip
If the tool operation returns structured data (for example, a JSON object), serialize it to a string before assigning it to function_resp. The tool role message content field must be a string.
Part 5: Control the loop
Wrap the LLM call, dispatcher, and request rebuild steps in a Jitterbit Script controller using a While loop. Place this controller at the top of the operation chain:
maxIterations = 5;
// First LLM call (always runs before the loop)
RunOperation("<TAG>operation:LLM Call</TAG>");
// Loop until the model returns a final response or the limit is reached
while($call_llm_again == true && $loop_count < maxIterations,
$loop_count = $loop_count + 1;
RunOperation("<TAG>operation:Tool Dispatcher</TAG>");
RunScript("<TAG>script:Append Tool Result</TAG>");
RunOperation("<TAG>operation:LLM Call</TAG>");
);
If($loop_count >= maxIterations && $call_llm_again == true,
WriteToOperationLog("Tool-calling loop reached " + maxIterations
+ " iterations without a final response. Last function: " + $function_name)
);
The LLM Call operation uses InAndOut as the request body and runs the Part 2 response parser on success. The Tool Dispatcher operation runs the Part 3 Case statement. Append Tool Result is the Part 4 JavaScript script, referenced here as a named script component using RunScript.
Setting a maximum iteration limit prevents runaway loops when a tool consistently returns an error and the model responds by requesting the same tool again.
Tip
Start with a limit of 5 iterations. Most workflows resolve in one or two rounds; consistently hitting the limit signals a prompt design or tool result issue rather than a need for a higher limit.
Alternative: Jitterbit Script message building
If you prefer to avoid JavaScript, the message accumulation in Part 4 can be implemented entirely in Jitterbit Script using string concatenation. The HR Agent implements this approach, using gpt.registeredTools as a project variable to hold the serialized tool schemas (equivalent to toolsJson in this guide), with call_llm_again controlling the loop.
The Jitterbit Script approach builds the assistant and tool result messages by concatenating JSON strings directly, rather than using JSON.parse and JSON.stringify. The accumulated array is maintained by extracting the assistant message from tools_resp using GetJSONString and appending the tool result as a formatted string:
// Extract the full assistant message object from the previous response
assistantMsgJson = GetJSONString($tools_resp, "/choices/0/message");
// Build the tool result message
escapedResp = Replace($function_resp, "\"", "\\\"");
toolResultJson = "{\"role\":\"tool\",\"tool_call_id\":\""
+ $tool_call_id + "\",\"content\":\"" + escapedResp + "\"}";
// Accumulate: start a new array or append to the existing one
If(Length($tools_messages) == 0,
$tools_messages = "[" + assistantMsgJson + "," + toolResultJson + "]"
,
$tools_messages = Left($tools_messages, Length($tools_messages) - 1)
+ "," + assistantMsgJson + "," + toolResultJson + "]"
);
// Rebuild the request by replacing the messages field
// (omitted: requires parsing and replacing the messages array in $InAndOut)
The full replacement of the messages field in InAndOut requires replacing the existing array value inside the JSON string, which is fragile if message content contains special characters. Use the JavaScript approach in Part 4 when possible; reserve the Jitterbit Script approach for projects where JavaScript is not available.
Verify the integration
-
Deploy and run the controller operation with a user message that requires exactly one tool call. Confirm in the operation logs that
loop_countincrements to 1 and that the tool operation ran and returned a result. -
In the logs, confirm that the second LLM call received the extended messages array (log
InAndOutusingWriteToOperationLogbefore calling the LLM) and that it returned a plain-text final response with notool_calls. -
Send a user message that requires two sequential tool calls (for example, look up a contact and then create a ticket for that contact). Confirm that
loop_countreaches 2, then inspect the accumulated messages.tools_messagesis a serialized JSON array string, so log it after the loop completes to see its contents:WriteToOperationLog("tools_messages: " + $tools_messages);In the logged string, confirm that there are four objects, alternating
"role":"assistant"and"role":"tool"(two of each): each round appends the assistant message that selected the tool followed by itstoolresult. Eachtoolmessage'stool_call_idshould match theidin the preceding assistant message'stool_callsentry. -
Send a message that does not require any tool call (for example, a general question the model can answer from its own knowledge). Confirm that the loop does not execute (the initial LLM call returns no
tool_calls,call_llm_againstaysfalse, andloop_countstays 0). -
If the API returns a 400 error citing an invalid
tool_call_id, log the value oftool_call_idand compare it to theidfield inchoices[0].message.tool_calls[0]from the previous LLM response. A mismatch typically meanstools_respwas updated before the ID was extracted. -
If the loop hits the iteration limit, log
InAndOutat the start of each round to inspect the accumulated messages. A repeating tool call for the same function with the same arguments indicates that the tool operation is returning an error result that the model is retrying, or that the tool description does not match the user's request.