Tool use patterns
Production patterns for tool calling with the Tase API: feeding results back, handling errors and empty results, confirming destructive actions, and running multi-step workflows.
The mechanics of tool calling are covered in Function calling. This page is about what separates a demo from a production integration: how results flow back, how failures are handled, and how to keep the model from doing something the user will regret.
Feed every result back — completely
After executing a call, the history you send back must contain both the assistant message that carried the tool_calls and one role: "tool" message per call, each with the matching tool_call_id. The most common integration bug is dropping the assistant tool-call message and sending only the results — the pairing breaks and the request fails.
[
{ "role": "user", "content": "How much did I spend on food this week?" },
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_9k1d",
"type": "function",
"function": {
"name": "get_spending_summary",
"arguments": "{\"category\": \"food\", \"period\": \"week\"}"
}
}
]
},
{
"role": "tool",
"tool_call_id": "call_9k1d",
"content": "{\"total\": 86.40, \"currency\": \"USD\", \"entries\": 9}"
}
]Every call gets an answer
tool_call_id must receive a role: "tool" message before the next assistant turn — including calls that failed or were cancelled. A missing result stalls the conversation.Errors and empty results are data
When a tool fails, don’t swallow the failure or abort the conversation. Return a structured error as the tool result: the model reads it, and can retry with corrected arguments, ask the user a clarifying question, or explain honestly that the operation didn’t work.
{
"role": "tool",
"tool_call_id": "call_9k1d",
"content": "{\"status\": \"error\", \"code\": \"NOT_FOUND\", \"message\": \"No task named 'Dentist' exists. Closest match: 'Dentist appointment'.\"}"
}- Distinguish empty from failed — a search with no hits should return
{"status": "ok", "results": []}, not an error. The model responds very differently to “nothing found” versus “the lookup broke”. - Include a recovery hint — “closest match”, valid value ranges, or the missing field name turn a dead end into a self-correcting retry.
- Keep results compact — tool results are prompt input. Return the fields the model needs, not the whole database row.
Destructive actions need a confirmation step
Deleting records, overwriting data, sending messages — anything hard to undo should never execute directly off a single tool call. Since your application owns execution, insert a confirmation step between the model’s intent and the side effect.
- 1
The model emits the intent
For example, adelete_taskcall with the target’s id in the arguments. - 2
Your app pauses the loop
Instead of executing, show the user exactly what would happen: “Delete the task ‘Renew passport’?” — with the real record, not the model’s paraphrase. - 3
Execute or cancel based on the answer
If confirmed, execute and return the normal result. If declined, return{"status": "cancelled", "reason": "user declined"}— the model acknowledges gracefully instead of retrying the deletion.
Two-tool design
request_delete that returns a confirmation token, and a confirm_delete that your app only allows after the user has approved in your UI. The dangerous path then cannot be triggered by a single call.Multi-step workflows
Real requests often chain calls: search for a record, read it, compute something, then create or update. The pattern is the same loop — keep completing while the model returns tool calls — but production loops must be bounded, so a confused model cannot burn tokens forever.
const MAX_TURNS = 8;
async function runConversation(messages) {
for (let turn = 0; turn < MAX_TURNS; turn++) {
const message = await chat(messages); // POST /v1/chat/completions
if (!message.tool_calls?.length) {
return message.content; // final answer
}
messages.push(message);
for (const call of message.tool_calls) {
const result = await executeTool(call); // your dispatcher
messages.push({
role: 'tool',
tool_call_id: call.id,
content: JSON.stringify(result),
});
}
}
throw new Error('Tool loop exceeded ' + MAX_TURNS + ' turns');
}- Detect repetition — if the model issues the same tool name with the same arguments twice in a row, return an explicit error (“this exact call already ran”) instead of executing again.
- Pass ids between steps — return record ids in results so later calls can reference them precisely instead of re-searching by name.
- Summarize long intermediates — when a step returns a large payload, feed the model a trimmed version; the full data can stay in your app.
Common mistakes and how to avoid them
- Confusing similar tools — two custom tools with overlapping descriptions (say, one for creating tasks and one for creating reminders) get mixed up. Make names disjoint and write contrastive descriptions: “Use for X. Not for Y — use the other tool for that.”
- Missing required arguments — declare them in the schema’s
requiredarray, validate on receipt, and when a field is absent return an error naming it. The model either re-calls with the field or asks the user for it. - Unnecessary tool calls — the model reaches for a search tool on a greeting, or looks up something it was just told. Scope descriptions with “Only use when…”, and set
tool_choice: "none"on surfaces where tools should never fire. - Executing before validating — model-produced arguments are untrusted input. Parse, check types and ranges, and parameterize anything that touches a database or shell.
- Unbounded loops — always cap the number of tool turns per user request, and surface a clean failure when the cap is hit.
Should tool results be JSON or plain text?+
Either — content is just a string. JSON is easier to keep consistent (a status field, error codes, ids), so it is the better default. Whichever you pick, keep results compact.
How do I stop the model from retrying a failing tool forever?+
Return progressively explicit errors, and after a retry budget respond with a result that says the operation is unavailable and should not be retried. Combine that with a hard cap on loop turns.
Can I execute parallel tool calls concurrently?+
Yes. Calls in a single tool_calls array are independent — run them concurrently, then append one role:"tool" message per call with the right tool_call_id before requesting the next completion.
Back to the fundamentals
The full request and response contract for tool calling — schemas, tool_choice, parallel calls, and complete code examples.
Function calling