Skip to Content

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:

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.

flowchart LR A["Script
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_calls to false in the request body built in Part 1.
  • Iterate over the tool_calls array, dispatching the tool and appending an assistant/tool message pair for each entry before the next LLM call. The LLM API requires a matching tool result for every tool_call_id in 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

  1. Deploy and run the controller operation with a user message that requires exactly one tool call. Confirm in the operation logs that loop_count increments to 1 and that the tool operation ran and returned a result.

  2. In the logs, confirm that the second LLM call received the extended messages array (log InAndOut using WriteToOperationLog before calling the LLM) and that it returned a plain-text final response with no tool_calls.

  3. 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_count reaches 2, then inspect the accumulated messages. tools_messages is 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 its tool result. Each tool message's tool_call_id should match the id in the preceding assistant message's tool_calls entry.

  4. 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_again stays false, and loop_count stays 0).

  5. If the API returns a 400 error citing an invalid tool_call_id, log the value of tool_call_id and compare it to the id field in choices[0].message.tool_calls[0] from the previous LLM response. A mismatch typically means tools_resp was updated before the ID was extracted.

  6. If the loop hits the iteration limit, log InAndOut at 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.