n4nAI

How to give an AI agent access to external tools

Practical steps to give AI agent access to tools via OpenAI-compatible tool calls, including schema design, execution, and fallback routing.

n4n Team3 min read657 words

Audio narration

Coming soon — every post will get a voice note here.

When you give AI agent access to tools, you stop treating the model as a chatbot and start treating it as a control plane for external systems. The OpenAI-compatible tool-calling protocol is the de facto standard for this: you send a JSON schema, the model returns a function name and arguments, and your code executes it. This guide shows the minimal end-to-end implementation an engineer needs to ship a tool-using agent without a heavyweight framework.

Step 1: Define explicit tool schemas

The first move to give AI agent access to tools safely is to describe each capability as a strict JSON Schema. The model sees only the schema, not your implementation, so the schema is both a UI and a security boundary. Keep it minimal: expose exactly the parameters the model needs and nothing more.

[
  {
    "type": "function",
    "function": {
      "name": "calculator",
      "description": "Evaluate a basic arithmetic expression",
      "parameters": {
        "type": "object",
        "properties": {
          "expression": {
            "type": "string",
            "description": "Infix arithmetic, e.g. (23 * 47) + 2"
          }
        },
        "required": ["expression"]
      }
    }
  },
  {
    "type": "function",
    "function": {
      "name": "fetch_url",
      "description": "Retrieve HTTP content from a public URL",
      "parameters": {
        "type": "object",
        "properties": {
          "url": { "type": "string" },
          "timeout_ms": { "type": "integer", "default": 5000 }
        },
        "required": ["url"]
      }
    }
  }
]

Schema design rules

  • Use description fields like you would document a public API; the model relies on them to pick the right tool.
  • Mark only truly required fields as required. Over-constraining forces the model to guess.
  • Never embed secrets or internal hostnames in the schema. If a tool needs a base URL, bake it into your server code, not the schema.

Step 2: Call the model with tools attached

Point an OpenAI-compatible client at your provider and send the tools list alongside the conversation. The tool_choice="auto" setting lets the model decide whether to call a tool or respond directly.

from openai import OpenAI
import json

client = OpenAI()  # swap base_url for a gateway later

tools = json.load(open("tools.json"))

messages = [{"role": "user", "content": "What is (23 * 47) + 2?"}]

resp = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=messages,
    tools=tools,
    tool_choice="auto",
)

msg = resp.choices[0].message
if not msg.tool_calls:
    print("Model answered directly:", msg.content)
else:
    for call in msg.tool_calls:
        print("Model requested:", call.function.name, call.function.arguments)

The response message contains tool_calls only when the model decides a tool is needed. Each call has a unique id you must echo back later.

Step 3: Execute tool calls with hard boundaries

When you give AI agent access to tools that perform writes or network calls, treat the model’s arguments as untrusted input. Run each tool in a constrained context: validate types, enforce timeouts, and isolate side effects.

import json
import requests

def run_calculator(expression: str) -> float:
    # Use a real safe parser in prod; eval is shown for structure only.
    return float(eval(expression))  # noqa: S307

def run_fetch_url(url: str, timeout_ms: int = 5000) -> str:
    r = requests.get(url, timeout=timeout_ms / 1000)
    return r.text[:2000]

def dispatch(call) -> str:
    name = call.function.name
    args = json.loads(call.function.arguments)
    if name == "calculator":
        return json.dumps({"result": run_calculator(args["expression"])})
    if name == "fetch_url":
        return json.dumps({"content": run_fetch_url(**args)})
    raise ValueError(f"Unknown tool: {name}")

Wrap dispatch in a timeout decorator or a subprocess if the tool can hang. Log the raw arguments and the returned payload; you will need this for debugging and for per-token cost attribution later.

Step 4: Feed results back and continue the loop

The model expects a tool role message for every tool_call_id it emitted. Append the assistant message (with tool_calls) and then the tool results, then call the model again to let it synthesize a final answer.

messages.append(msg)  # assistant message with tool_calls

for call in msg.tool_calls:
    result = dispatch(call)
    messages.append({
        "role": "tool",
        "tool_call_id": call.id,
        "content": result,
    })

resp2 = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=messages,
    tools=tools,
)
print("Final answer:", resp2.choices[0].message.content)

This two-turn pattern is the core loop. For multi-step plans, repeat Steps 2–4 until the model returns no tool_calls.

Step 5: Route through a resilient gateway

If you give AI agent access to tools in production, provider rate limits and regional outages will eventually break your agent mid-task. An OpenAI-compatible endpoint that aggregates 240+ models and automatically falls back when a provider is rate-limited or degraded removes a class of incidents without changing your calling code. For example, point the same OpenAI client at n4n.ai and keep your tool schemas intact:

client = OpenAI(
    base_url="https://api.n4n.ai/v1",
    api_key="your-key",
)

The gateway honors your tool_choice directives and forwards provider cache-control hints, so repeated schema payloads cost fewer tokens on supported backends. Per-token usage metering in the response lets you attribute spend to specific agent runs.

Step 6: Verify the integration end to end

Success means the agent calls the right tool, receives the result, and produces a correct final answer. Write a fast test that mocks the tool side effect and asserts the call happened.

def test_agent_uses_calculator(monkeypatch):
    captured = {}
    def fake_dispatch(call):
        captured["name"] = call.function.name
        return '{"result": 1083}'
    monkeypatch.setattr("__main__.dispatch", fake_dispatch)

    messages = [{"role": "user", "content": "What is (23 * 47) + 2?"}]
    resp = client.chat.completions.create(
        model="gpt-4o-mini", messages=messages, tools=tools)
    msg = resp.choices[0].message
    assert msg.tool_calls, "Expected a tool call"
    for call in msg.tool_calls:
        dispatch(call)
    assert captured["name"] == "calculator"

For manual verification, run the script from Step 4 and confirm the printed final answer contains 1083 (since 23*47=1081, +2 = 1083). If the model answers without calling the tool, tighten the description or set tool_choice={"type": "function", "function": {"name": "calculator"}} to force it.

Operational checklist

  • Tool schemas are versioned alongside code.
  • Every tool_call_id gets exactly one tool response.
  • Timeouts and size caps on tool output prevent context overflow.
  • Provider fallback is configured so a single 429 doesn’t kill the task.

Follow these steps and you have a baseline agent that can act, not just talk. Expand the tool set incrementally; each new schema is a new capability with a new attack surface, so review them like you would any external API.

Tagsai-agentstool-usetool-integration

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All ai agent tool use design patterns posts →