We use cookies. Cookies and browser storage keep you signed in and remember your preferences. With your permission we also measure how the site is used. You can any time. See our cookie policy.

Guide: tool-calling agents

Getting started

Guide: tool-calling agents

Build a tool-calling agent on api14 with a parallel tool loop, a step limit, structured output and cost controls for production use.

An agent is a loop: the model reads the conversation, decides to call one or more of your tools, you run them and send the results back, and the model continues until it can answer. api14 supports this with the OpenAI tools format on every tool-capable model, so the same code works with OpenAI, Anthropic, Google and other providers by changing the model id.

This guide builds a small agent with a step limit, parallel tool execution, a structured final answer and cost controls. Code uses the official openai package with base URL https://api14.com/api/v1.

Define tools

Each tool is a name, a description the model reads, and a JSON Schema for its arguments. Keep descriptions specific; they are the model's only documentation.

import json, os
from openai import OpenAI

client = OpenAI(base_url="https://api14.com/api/v1", api_key=os.environ["API14_API_KEY"])

def search_orders(customer_email: str) -> list:
    return [{"id": "A-1001", "status": "shipped"}]  # your database query

def get_shipment(order_id: str) -> dict:
    return {"order_id": order_id, "carrier": "UPS", "eta": "2026-10-09"}  # your API call

TOOLS = {"search_orders": search_orders, "get_shipment": get_shipment}

tools = [
    {"type": "function", "function": {
        "name": "search_orders",
        "description": "List a customer's orders with their status.",
        "parameters": {"type": "object", "properties": {"customer_email": {"type": "string"}},
                       "required": ["customer_email"]},
    }},
    {"type": "function", "function": {
        "name": "get_shipment",
        "description": "Carrier and estimated delivery date for one order.",
        "parameters": {"type": "object", "properties": {"order_id": {"type": "string"}},
                       "required": ["order_id"]},
    }},
]

The loop with a step limit

Every iteration is a separate billed request, so always cap the number of steps. Return tool errors to the model as content instead of crashing; models usually recover by fixing the arguments.

MAX_STEPS = 8

def run_tool(call):
    try:
        args = json.loads(call.function.arguments)
        result = TOOLS[call.function.name](**args)
    except Exception as e:  # bad JSON, unknown tool, or your function failed
        result = {"error": str(e)}
    return {"role": "tool", "tool_call_id": call.id, "content": json.dumps(result)}

def agent(task, model="openai/gpt-5-mini"):
    messages = [
        {"role": "system", "content": "You are a support agent. Use tools to look things up. Be brief."},
        {"role": "user", "content": task},
    ]
    spent = 0.0
    for step in range(MAX_STEPS):
        r = client.chat.completions.create(model=model, messages=messages, tools=tools, max_tokens=1000)
        spent += r.usage.cost
        msg = r.choices[0].message
        if not msg.tool_calls:
            return msg.content, spent
        messages.append(msg)
        messages.extend(run_tool(call) for call in msg.tool_calls)
    raise RuntimeError(f"No answer after {MAX_STEPS} steps (spent ${spent:.4f})")

print(agent("Where is my order? My email is ada@example.com"))

Send tools with every request in the loop. Many providers reject a tool message when the request has no tool definitions.

Parallel tool calls

With parallel_tool_calls (on by default where supported), a model can request several calls in one turn, for example get_shipment for three orders. Run them concurrently and append one tool message per call, in any order, tool_call_id links each result to its call. In Node:

import OpenAI from "openai";

const client = new OpenAI({ baseURL: "https://api14.com/api/v1", apiKey: process.env.API14_API_KEY });
const MAX_STEPS = 8;

async function agent(messages, tools, impl, model = "openai/gpt-5-mini") {
  for (let step = 0; step < MAX_STEPS; step++) {
    const res = await client.chat.completions.create({ model, messages, tools, max_tokens: 1000 });
    const msg = res.choices[0].message;
    if (!msg.tool_calls?.length) return msg.content;

    messages.push(msg);
    const results = await Promise.all(msg.tool_calls.map(async (call) => {
      let content;
      try {
        content = JSON.stringify(await impl[call.function.name](JSON.parse(call.function.arguments)));
      } catch (err) {
        content = JSON.stringify({ error: String(err) });
      }
      return { role: "tool", tool_call_id: call.id, content };
    }));
    messages.push(...results);
  }
  throw new Error(`No answer after ${MAX_STEPS} steps`);
}

If your tools have side effects that must happen in order (create, then update), set parallel_tool_calls: false so the model asks for one call per turn.

Structured final output

When the agent's answer feeds other code, ask for JSON that matches a schema. A simple, portable pattern is one extra call after the loop has gathered the facts (messages here is the conversation from the loop; return it from agent() alongside the answer):

schema = {
    "type": "object",
    "properties": {
        "order_id": {"type": "string"},
        "status": {"type": "string", "enum": ["processing", "shipped", "delivered", "unknown"]},
        "eta": {"type": ["string", "null"]},
        "reply": {"type": "string"},
    },
    "required": ["order_id", "status", "eta", "reply"],
    "additionalProperties": False,
}

final = client.chat.completions.create(
    model="openai/gpt-5-mini",
    messages=messages + [{"role": "user", "content": "Summarize the result for our ticket system."}],
    tools=tools,
    tool_choice="none",
    response_format={"type": "json_schema", "json_schema": {"name": "ticket", "strict": True, "schema": schema}},
)
ticket = json.loads(final.choices[0].message.content)

tool_choice: "none" keeps tools in the request (needed because the history contains tool messages) while stopping further calls. Always validate the parsed JSON. See Structured outputs.

Choosing a model

  • Capability: the model must list tools in supported_parameters. Check with GET /models, see Models. For the final step, look for structured_outputs or response_format.
  • Routing: add provider: { "require_parameters": true } (in Python, via extra_body) so requests only go to providers that support every parameter you send. See Provider routing.
  • Size: small models such as openai/gpt-5-mini handle a few well-described tools reliably and cheaply. Move to a larger model when the agent has many tools, long multi-step plans, or keeps picking the wrong tool.
  • Test with your tools: run the same 20–50 real tasks against two or three candidate models and compare success rate, steps per task and total usage.cost.
curl -s "https://api14.com/api/v1/models?category=text" \
  | jq -r '.data[] | select(.supported_parameters | index("tools")) | "\(.id)  \(.pricing.prompt)"'

Controlling cost

An agent can make many requests for one task, and the whole conversation is resent on every step. Limit spend at several levels:

  • Step limit (MAX_STEPS) and max_tokens per request, as above.
  • Budget per task: sum usage.cost across steps and stop when it passes your threshold.
  • Short tool results: return only the fields the model needs. Large tool outputs are paid as input tokens on every later step.
  • Per-key daily USD limit: create a dedicated key for the agent in API keys and set a daily limit. When it is reached, requests return 429 daily_limit_reached until the next UTC day, handle that code by stopping the agent, not by retrying.
  • Model allowlist: restrict the key to the models you tested; anything else returns 403 model_not_allowed.
  • Prompt caching: keep the system prompt and tool definitions identical and first in every request so cached-prefix discounts apply. See Prompt caching.

See Tool calling, Rate limits and Errors.