Tool calling
Tool calling lets the model ask your program to do something it cannot do alone: look up an order, read a database row, check today’s exchange rate. You describe the functions you offer; when the model decides one would help, it replies with the function name and arguments instead of plain text. Your code runs the function and sends the result back, and the model uses it to finish its answer.
The model never runs anything itself. Every tool is code you wrote, running on your own systems, and your code decides whether to run it at all.
When to use tools
- Fresh or private data: stock levels, account details, anything the model cannot know.
- Actions: creating a ticket, sending a message, booking a slot. Ask the user to confirm before anything that cannot be undone.
- Exact work: arithmetic, date maths, unit conversion, where a few lines of code beat a guess.
- Reliable structure: when you need fields in a fixed shape, a tool with a strict argument schema is often more dependable than asking for JSON in plain text.
Today the Syntic API has no built-in, server-run tools: every tool is one you define and execute. Built-in tools that Syntic runs for you, such as web search and web fetch, are planned; see Planned additions.
Describe your tools
Send a tools array with each request. Every entry is a function with a name, a plain-language description and a JSON Schema for its arguments:
{
"model": "Amara S1.2",
"stream": false,
"messages": [
{ "role": "user", "content": "Is order 58213 on its way yet?" }
],
"tools": [
{
"type": "function",
"function": {
"name": "get_order_status",
"description": "Look up the shipping status of a customer order by its number. Use this whenever the user asks where an order is or when it will arrive.",
"parameters": {
"type": "object",
"properties": {
"order_number": { "type": "string", "description": "The order number, digits only." }
},
"required": ["order_number"]
}
}
}
]
}| Field | Notes |
|---|---|
type | Always "function". |
function.name | Letters, digits, underscores and dashes. Keep it short and clear. |
function.description | Its purpose, the situations that call for it, and any it should stay out of. The model picks tools by reading this, so it matters more than the name. |
function.parameters | A JSON Schema object describing the arguments. Mark the ones the function cannot work without as required. |
A good description says what the tool returns and its limits, for example “Only covers orders from the last 12 months.” Add a description to each property too, with units and formats (“ISO 8601 date”, “amount in cents”).
Control when tools are used
tool_choice is passed through to the model with your request:
| Value | Effect |
|---|---|
"auto" (or leave it out) | The model decides whether to call a tool. |
"none" | The model answers in text only, even though tools are listed. |
{"type": "function", "function": {"name": "get_order_status"}} | The model must call that function. |
The round trip
The model asks for a tool
The reply’s finish_reason is tool_calls, and the assistant message carries a tool_calls array instead of (or as well as) text:
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_7f2a",
"type": "function",
"function": { "name": "get_order_status", "arguments": "{\"order_number\":\"58213\"}" }
}
]
}arguments is a JSON string. Parse it, and validate it before you act on it.
Your code runs the function
Call your own function with the parsed arguments. Here it might return {"status": "shipped", "carrier": "DHL", "eta": "Thursday"}.
Send the result back
Make a new request that repeats the conversation, adds the assistant message from step 1 unchanged, and then adds one tool message per call, linked by tool_call_id:
{ "role": "tool", "tool_call_id": "call_7f2a", "content": "{\"status\":\"shipped\",\"carrier\":\"DHL\",\"eta\":\"Thursday\"}" }Send the same tools array again: the model needs it to read the history and in case it wants another call.
The model answers
With the result in hand, the model writes its reply to the user, or asks for another tool if it needs more.
A complete round trip in code
import json, os
from openai import OpenAI
client = OpenAI(base_url="https://app.syntic.ai/v1", api_key=os.environ["SYNTIC_API_KEY"])
def get_order_status(order_number: str) -> dict:
# Replace with a real lookup.
return {"order_number": order_number, "status": "shipped", "carrier": "DHL", "eta": "Thursday"}
tools = [{
"type": "function",
"function": {
"name": "get_order_status",
"description": "Look up the shipping status of a customer order by its number.",
"parameters": {
"type": "object",
"properties": {"order_number": {"type": "string", "description": "Digits only."}},
"required": ["order_number"],
},
},
}]
messages = [{"role": "user", "content": "Is order 58213 on its way yet?"}]
first = client.chat.completions.create(
model="Amara S1.2", stream=False, messages=messages, tools=tools,
)
msg = first.choices[0].message
if msg.tool_calls:
messages.append(msg.model_dump(exclude_none=True)) # keep the call in the history
for call in msg.tool_calls:
args = json.loads(call.function.arguments)
result = get_order_status(**args)
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps(result),
})
second = client.chat.completions.create(
model="Amara S1.2", stream=False, messages=messages, tools=tools,
)
print(second.choices[0].message.content)
else:
print(msg.content)This handles one round. Real tasks often need several: the model looks something up, then asks for a second tool based on the answer. The guide Write a tool loop repeats these steps until the model stops asking.
Several calls at once
A single reply can contain more than one entry in tool_calls, for example when the user asks about two orders. Run each call (in parallel if they are independent), then add one tool message per call, each with its own tool_call_id, before you send the next request. Every call id in the assistant message needs a matching result.
When a tool fails
Do not drop the call. Send a tool message that explains what went wrong, in words the model can act on:
{ "role": "tool", "tool_call_id": "call_7f2a", "content": "{\"error\":\"No order 58213 found. Order numbers are 6 digits.\"}" }The model can then ask the user for the right value, try a different tool or explain the problem. If the arguments themselves were invalid, say which field was wrong; the model usually corrects itself on the next call.
Tool calls and streaming
Tool calls work with streaming too. The call’s name and arguments arrive in pieces that you join before parsing. See Tool calls in a stream.
Cost
Tool definitions are sent as part of the input on every request, so they count as input tokens. Each round trip is a separate request, billed on its own. Keep descriptions focused, list only the tools a task needs, and keep results compact. Because the start of the conversation repeats between rounds, much of that input is often billed at the cheaper cached rate (see Chat basics).
Safety checklist
Arguments are written by the model, and the model may be steered by text in the conversation, including text that came from a web page, an email or a previous tool result. Treat arguments as untrusted input.
- Validate every argument against your own rules before acting on it.
- Never pass arguments straight into a shell command, SQL query or file path.
- Ask the user to confirm actions that spend money, send messages or delete data.
- Put a ceiling on the number of rounds so a confused conversation cannot loop forever.
- Give each tool the narrowest permissions it needs on your side.
Planned additions
Tool definition checks
Tool definitions will be checked before the model runs. Today a malformed definition is passed on and may fail with a vague error from the model service, or be ignored. After the change, problems return 400 in the standard error envelope, with param pointing at the exact place:
{
"error": {
"type": "invalid_request",
"code": "invalid_tool_schema",
"message": "tools[2].function.parameters must be a JSON Schema object with \"type\": \"object\".",
"param": "tools[2].function.parameters"
},
"request_id": "req_01JBXH4"
}| Check | code |
|---|---|
type is "function" or a known built-in tool type | invalid_tool_type |
function.name matches ^[a-zA-Z0-9_-]{1,64}$ | invalid_tool_name |
Names are unique across tools | duplicate_tool_name |
function.parameters is a JSON Schema object with "type": "object" | invalid_tool_schema |
tool_choice names a function that exists in tools | unknown_tool_choice |
Every tool message answers a tool_calls id from the previous assistant message | unmatched_tool_result |
Documented limits
| Limit | Value |
|---|---|
| Tools per request | 128 (use tool search for larger catalogs) |
| Description length | 1,024 characters per tool |
| Schema size | 16 KB per tool, 256 KB for all tools together |
| Schema nesting depth | 8 |
More tool_choice values
| Value | Effect |
|---|---|
"required" | The model must call at least one tool, its choice of which. |
{"type": "function", "function": {"name": "..."}} | Guaranteed on every model (today it is passed through and depends on the model). |
Related planned features
- Parallel tool calls: turn off several calls per reply with
parallel_tool_calls: false. - Strict tool arguments:
function.strict: trueguarantees arguments match your schema. - Built-in tools: web search, web fetch, code execution.
- Ready-made client tools: computer use, text editor, shell and memory.
- MCP connector: connect remote tool servers in the request.
Build notes for the platform team
- Validate in
api-proxybefore billing and before calling the model service; reuse the JSON Schema validator planned for JSON output. - Add the limits to
openapi.yamland test each error code.