Docs
Guides

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

Each 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. 1

    The model emits the intent

    For example, a delete_task call with the target’s id in the arguments.
  2. 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. 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

For custom tools, an alternative is to split the operation: a safe 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 required array, 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