Write a tool loop
A single tool call answers a single question. Real tasks usually need several: look something up, use the result to look up something else, then compute an answer. A tool loop automates that back-and-forth. Your code sends the conversation, the model either answers or asks for one or more functions, your code runs them and sends the results back, and the cycle repeats until the model gives a final answer.
This guide builds a complete loop for a small weather assistant, first in Python and then in Node.js, and then covers what to add before it goes to production. If tool calling is new to you, read Tool calling first.
How the loop flows
your code Syntic API
───────── ──────────
messages + tools ───────────────────────▶
◀─────────────────────── assistant: tool_calls [current_weather(Lisbon), current_weather(Oslo)]
run both, append two "tool" messages
messages + tools ───────────────────────▶
◀─────────────────────── assistant: tool_calls [convert_c_to_f(21), convert_c_to_f(4)]
run both, append two "tool" messages
messages + tools ───────────────────────▶
◀─────────────────────── assistant: "Lisbon is warmer: 69.8 °F against 39.2 °F in Oslo."
doneThree rules make it work:
- Every round sends the whole conversation so far, plus the same
toolslist. - The assistant message that asked for tools is appended exactly as received, including its
tool_calls. - Each requested call gets one
toolmessage whosetool_call_idmatches the call’sid.
The code
import json
import os
from openai import OpenAI
client = OpenAI(base_url="https://app.syntic.ai/v1", api_key=os.environ["SYNTIC_API_KEY"])
# 1. The functions the model may call. Replace the bodies with real lookups.
def current_weather(city: str) -> dict:
sample = {"Lisbon": (21, "sunny"), "Oslo": (4, "sleet")}
temp, sky = sample.get(city, (15, "cloudy"))
return {"city": city, "temp_c": temp, "sky": sky}
def convert_c_to_f(celsius: float) -> dict:
return {"fahrenheit": round(celsius * 9 / 5 + 32, 1)}
FUNCTIONS = {"current_weather": current_weather, "convert_c_to_f": convert_c_to_f}
# 2. How the functions are described to the model.
TOOLS = [
{
"type": "function",
"function": {
"name": "current_weather",
"description": "Current temperature in Celsius and sky conditions for a city.",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string", "description": "City name, e.g. Lisbon"}},
"required": ["city"],
},
},
},
{
"type": "function",
"function": {
"name": "convert_c_to_f",
"description": "Convert a temperature from Celsius to Fahrenheit.",
"parameters": {
"type": "object",
"properties": {"celsius": {"type": "number"}},
"required": ["celsius"],
},
},
},
]
MAX_ROUNDS = 6
def run_tool(call) -> str:
fn = FUNCTIONS.get(call.function.name)
if fn is None:
return json.dumps({"error": f"unknown tool {call.function.name}"})
try:
args = json.loads(call.function.arguments or "{}")
return json.dumps(fn(**args))
except Exception as exc: # report the failure to the model instead of crashing
return json.dumps({"error": f"{type(exc).__name__}: {exc}"})
def run(question: str) -> str:
messages = [
{"role": "system", "content": "You are a concise weather assistant. Use the tools for any facts."},
{"role": "user", "content": question},
]
for _ in range(MAX_ROUNDS):
reply = client.chat.completions.create(
model="Amara S1.2", stream=False, messages=messages, tools=TOOLS, max_tokens=1024,
)
message = reply.choices[0].message
# 3. No tool requested: this is the final answer.
if not message.tool_calls:
return message.content
# 4. Keep the assistant turn exactly as received, then answer each call.
messages.append(message.model_dump(exclude_none=True))
for call in message.tool_calls:
messages.append({"role": "tool", "tool_call_id": call.id, "content": run_tool(call)})
return "Stopped: the task needed more tool rounds than allowed."
print(run("Is it warmer in Lisbon or Oslo right now? Give both in Fahrenheit."))How it works
- Functions and their descriptions live side by side, so adding a tool means adding one function and one description.
- Each round sends the whole conversation and the tool list. The API keeps no conversation state between requests.
- When the reply has no tool calls, the loop returns its text.
- Otherwise the assistant message is appended unchanged, followed by one
toolmessage per call, matched bytool_call_id. The model may ask for several tools in one turn; the Node.js version runs them in parallel. - Tool errors become tool results. An exception is turned into
{"error": ...}and sent back, so the model can try different arguments or explain the problem instead of the loop crashing. MAX_ROUNDSstops a conversation that keeps asking for tools.
Steering tool use with tool_choice
tool_choice controls whether the model may, must, or must not call a tool on a given round:
| Value | Effect |
|---|---|
"auto" (default when tools are present) | The model decides. |
"none" | The model must answer in text. Useful for a final “summarize what you found” round. |
"required" | The model must call at least one tool. |
{"type": "function", "function": {"name": "current_weather"}} | The model must call that specific function. |
A common pattern is to force a final text answer when the loop is about to run out of rounds:
for round_number in range(MAX_ROUNDS):
last_round = round_number == MAX_ROUNDS - 1
reply = client.chat.completions.create(
model="Amara S1.2", stream=False, messages=messages, tools=TOOLS,
tool_choice="none" if last_round else "auto",
)
# ... the rest of the loop body is unchangedStreaming a tool loop
The loop above uses stream: false to keep the code short. With streaming (the API’s default), tool calls arrive in pieces: each chunk’s delta.tool_calls carries an index, and the id, function name and arguments text must be joined per index until the stream ends. Only then can you parse the arguments and run the tool. Stream when a person is watching the final answer appear; for back-end automation, stream: false is simpler. See Streaming.
Cost of a loop
Every round re-sends the full conversation, so prompt tokens grow with each round. Two things keep this affordable:
- Caching. Because each round only appends to the conversation, the earlier part matches the previous request and is billed at the cheaper cached-input rate. Never reorder or edit earlier messages inside a loop.
- Short tool results. A tool result is sent again on every later round. Return the fields the model needs, not whole database rows or full web pages.
Pricing has a worked example of a five-round loop.
Before production
- Validate arguments. Arguments are text produced by a model. Check types, ranges and permissions in your own code before acting, especially for anything that writes data, sends messages or spends money.
- Require confirmation for side effects. For destructive actions, have the tool return “needs confirmation” and let a person approve before running it for real.
- Time-box tools. Give each tool a timeout and return an error result if it is exceeded, so one slow dependency does not stall the loop.
- Log each round. Record the tool name, arguments, result size and the response’s
x-syntic-request-id. - Handle limits. A long loop is more likely to meet a
429or402part-way through. Use the wrapper from Handle usage limits and decide whether a half-finished task should resume or restart. - Pick the model deliberately. For loops over source code,
Syntic Codeis built for the job; see Choosing a model.
Tool definitions count as prompt tokens on every round. Ten tools with long descriptions can add more to each request than the conversation itself, so include only the tools a task needs.
Related pages
- Tool calling: the request and response fields in detail.
- Build a support assistant: a tool loop inside a customer-facing chat.
- Chat completions reference: every field of the endpoint.