# How to Give an AI Agent Tools

- Tool: Any LLM or framework
- Difficulty: Intermediate
- Time required: 30-45 min
- Compatibility: Python 3.10+, openai-agents 0.24+, an OpenAI API key, any OS
- Last updated: November 2026

## TL;DR

An AI agent tool is a typed function the model can call, described well enough that the model knows when to use it. This guide builds an IT helpdesk agent with three tools: a read-only disk-usage check, a service restart, and a password reset, and shows how to write each tool's schema, handle its errors, and separate what the agent can run freely from what it can't. Budget 30-45 minutes and an OpenAI API key.

## What you'll build

You'll build an IT helpdesk agent for a small engineering team. It can check disk usage on a server (read-only, safe to run automatically), restart a background service (state-changing, logged and rate-limited), and reset a user's password (destructive, the one tool this guide deliberately keeps separate from the rest). The agent itself is simple; the point of this guide is what makes a tool good, not what makes an agent loop clever.

A tool is three things the model actually uses: its name, its description (usually the function's docstring, which the model reads to decide when to call it), and its parameter schema (inferred from the function's type hints). Get the description vague and the model either never calls the tool or calls it when it shouldn't. Get the schema too loose, a bare str where you meant an enum of three service names, and the model will eventually pass something your handler can't use.

Tools are the right primitive when the task is a specific, boundable action: look up a value, change one row, send one message. When the task instead needs multiple specialists coordinating or a long-running multi-stage plan, reach for orchestration (how-to-orchestrate-ai-agents) instead of trying to model it as one agent with twenty tools.

## Before you start

- Python 3.10+ and a terminal
- An OpenAI API key (OPENAI_API_KEY) with available credits
- pip install openai-agents
- Basic comfort with Python type hints and docstrings

## Step-by-step guide

### 1. Install the SDK and define the read-only tool

Install openai-agents, then write check_disk_usage as a plain typed function and wrap it with @tool from agents.decorators. The docstring is not documentation for humans here, it's the tool's description string the model reads verbatim, so write it the way you'd brief a new hire: what it does, what the argument means, what it returns.

```
pip install openai-agents
export OPENAI_API_KEY="sk-..."

from agents.decorators import tool

SERVERS = {"web-01": {"disk_used_pct": 42}, "web-02": {"disk_used_pct": 91}}

@tool
def check_disk_usage(server: str) -> str:
    """Check disk usage percentage for a named server.

    Args:
        server: The server hostname, e.g. 'web-01'.
    """
    info = SERVERS.get(server)
    if not info:
        return f"No server named {server}. Known servers: {list(SERVERS)}"
    return f"{server} is at {info['disk_used_pct']}% disk usage."
```

**Expected result:** check_disk_usage is a FunctionTool object, importable and ready to attach to an Agent.

### 2. Define the state-changing tool with a custom error handler

restart_service actually changes something, so give it a failure_error_function: a function that turns a raised exception into a string the model can read and react to, instead of letting the SDK's default error text leak internal detail. Restrict the server argument to a short enum-like check in the body rather than trusting the model to only pass known values.

```
from agents import RunContextWrapper
from typing import Any

def friendly_restart_error(ctx: RunContextWrapper[Any], error: Exception) -> str:
    return f"Restart failed: {error}. Try a different service name or escalate to on-call."

@tool(failure_error_function=friendly_restart_error)
def restart_service(server: str, service: str) -> str:
    """Restart a named service on a server. Logs the action.

    Args:
        server: The server hostname, e.g. 'web-01'.
        service: The service name, e.g. 'nginx' or 'worker'.
    """
    if server not in SERVERS:
        raise ValueError(f"Unknown server: {server}")
    print(f"[AUDIT] restart_service({server}, {service})")
    return f"Restarted {service} on {server}."
```

**Expected result:** Calling restart_service.on_invoke_tool directly with a bad server name returns a readable message instead of a raw traceback.

### 3. Define the destructive tool and keep it isolated

reset_password is the tool that should never run unattended. Write it with the same shape as the others, but don't attach it to the same agent as a default; this is the design decision that matters more than any code here. Keep destructive tools in their own list so the next step's Agent only sees them when you explicitly opt in.

```
@tool
def reset_password(user_email: str) -> str:
    """Reset a user's password and email them a temporary one.
    Destructive: locks the user out of their current password immediately.

    Args:
        user_email: The user's email address.
    """
    print(f"[AUDIT] reset_password({user_email})")
    return f"Password reset for {user_email}. Temporary password emailed."

SAFE_TOOLS = [check_disk_usage, restart_service]
DESTRUCTIVE_TOOLS = [reset_password]
```

**Expected result:** Two separate lists exist; nothing has been attached to an Agent yet, which is intentional.

### 4. Attach the safe tools to an Agent and run it

Build the Agent with only SAFE_TOOLS and a system instruction that tells it what it can and can't do on its own. Run.run_sync() executes the full tool-calling loop: the model decides to call check_disk_usage, the SDK runs it, feeds the result back, and the model composes a final answer.

```
from agents import Agent, Runner

helpdesk = Agent(
    name="IT Helpdesk",
    instructions=(
        "You help with server operations. You can check disk usage and restart "
        "services freely. You do not have access to password resets; tell the "
        "user to escalate those to a human."
    ),
    tools=SAFE_TOOLS,
)

result = Runner.run_sync(helpdesk, "web-02 feels slow, can you check it and restart nginx if disk is over 90%?")
print(result.final_output)
```

**Expected result:** The agent calls check_disk_usage, sees 91%, calls restart_service, and reports both actions in its final answer.

### 5. Add the destructive tool behind an explicit opt-in

Build a second agent that does include reset_password, and only invoke that agent from a code path your application gates separately, for example after a support-ticket workflow confirms identity. The point isn't that this code enforces approval; it's that giving a different agent instance a different tool list is the cheapest form of access control you have before you need a real permission system.

```
helpdesk_privileged = Agent(
    name="IT Helpdesk (privileged)",
    instructions=helpdesk.instructions + " You may reset passwords after identity is confirmed.",
    tools=SAFE_TOOLS + DESTRUCTIVE_TOOLS,
)

# Only reachable from a code path that already verified identity
result = Runner.run_sync(helpdesk_privileged, "Confirmed identity for alex@company.com, reset their password")
```

**Expected result:** The privileged agent calls reset_password; the default-instance agent from step 4 would refuse because the tool isn't in its list at all.

### 6. Harden it: validate inputs before they reach your systems

The model can still pass a syntactically valid but semantically wrong argument, like a server name that exists but belongs to a different team. Add a validation layer inside each tool, not just a type hint, and return a clear string on rejection so the model can ask a follow-up instead of your handler silently doing the wrong thing.

```
ALLOWED_SERVERS_FOR_THIS_AGENT = {"web-01", "web-02"}

@tool
def check_disk_usage(server: str) -> str:
    """Check disk usage percentage for a named server."""
    if server not in ALLOWED_SERVERS_FOR_THIS_AGENT:
        return f"{server} is outside this agent's scope. Allowed: {ALLOWED_SERVERS_FOR_THIS_AGENT}"
    info = SERVERS.get(server, {"disk_used_pct": 0})
    return f"{server} is at {info['disk_used_pct']}% disk usage."
```

**Expected result:** Asking about a server outside the allowed set returns a scoping message instead of a KeyError or a made-up number.

## Complete code example

File: `it_helpdesk_agent.py`

```python
"""IT helpdesk agent: a read-only check, a logged state change, and a
destructive tool kept in a separate, opt-in tool list.
Run: python it_helpdesk_agent.py (pip install openai-agents; set OPENAI_API_KEY)
"""
from typing import Any

from agents import Agent, Runner, RunContextWrapper
from agents.decorators import tool

SERVERS = {
    "web-01": {"disk_used_pct": 42},
    "web-02": {"disk_used_pct": 91},
}
ALLOWED_SERVERS = set(SERVERS)


@tool
def check_disk_usage(server: str) -> str:
    """Check disk usage percentage for a named server.

    Args:
        server: The server hostname, e.g. 'web-01'.
    """
    if server not in ALLOWED_SERVERS:
        return f"{server} is outside this agent's scope. Allowed: {ALLOWED_SERVERS}"
    return f"{server} is at {SERVERS[server]['disk_used_pct']}% disk usage."


def friendly_restart_error(ctx: RunContextWrapper[Any], error: Exception) -> str:
    return f"Restart failed: {error}. Try a different service name or escalate to on-call."


@tool(failure_error_function=friendly_restart_error)
def restart_service(server: str, service: str) -> str:
    """Restart a named service on a server. Logs the action.

    Args:
        server: The server hostname, e.g. 'web-01'.
        service: The service name, e.g. 'nginx' or 'worker'.
    """
    if server not in SERVERS:
        raise ValueError(f"Unknown server: {server}")
    print(f"[AUDIT] restart_service({server}, {service})")
    return f"Restarted {service} on {server}."


@tool
def reset_password(user_email: str) -> str:
    """Reset a user's password and email them a temporary one.
    Destructive: locks the user out of their current password immediately.

    Args:
        user_email: The user's email address.
    """
    print(f"[AUDIT] reset_password({user_email})")
    return f"Password reset for {user_email}. Temporary password emailed."


SAFE_TOOLS = [check_disk_usage, restart_service]
DESTRUCTIVE_TOOLS = [reset_password]

helpdesk = Agent(
    name="IT Helpdesk",
    instructions=(
        "You help with server operations. You can check disk usage and restart "
        "services freely. You do not have access to password resets; tell the "
        "user to escalate those to a human."
    ),
    tools=SAFE_TOOLS,
)

helpdesk_privileged = Agent(
    name="IT Helpdesk (privileged)",
    instructions=helpdesk.instructions + " You may reset passwords after identity is confirmed.",
    tools=SAFE_TOOLS + DESTRUCTIVE_TOOLS,
)

if __name__ == "__main__":
    result = Runner.run_sync(
        helpdesk,
        "web-02 feels slow, can you check it and restart nginx if disk is over 90%?",
    )
    print(result.final_output)
```

## Common mistakes

- **Writing a one-line docstring like 'restarts a service' with no argument guidance** — undefined Fix: Describe every argument in the docstring, including valid values and format. The model only knows what the description tells it; it can't read your source code.
- **Putting a destructive tool in the same tools list as read-only ones out of convenience** — undefined Fix: Split tool lists by risk from the start. Adding an approval layer later is much harder if every agent instance already has blanket access.
- **Letting a tool raise an uncaught exception with no failure_error_function** — undefined Fix: The default error message is generic. Write a failure_error_function that tells the model what went wrong and what to try instead.
- **Trusting the type hint alone to constrain what the model passes** — undefined Fix: A str type hint doesn't stop the model from passing a server name that doesn't exist. Validate inside the function body and return a clear rejection string.
- **Creating a new tool for every minor variation of the same action** — undefined Fix: If two tools differ only by a parameter value, that's one tool with an argument, not two tools. Too many near-duplicate tools make the model less reliable at picking the right one.

## Best practices

- Write tool descriptions the way you'd brief a new hire: when to use it, when not to, and what it returns.
- Group tools into explicit risk tiers (read-only, state-changing, destructive) before you write a single line of agent code.
- Give every state-changing or destructive tool a failure_error_function; don't rely on the SDK's default error text.
- Validate arguments inside the tool body, not just through type hints; the model can produce a valid-looking but wrong value.
- Log every call to a state-changing or destructive tool before it executes, independent of whether it succeeds.
- Keep tools few and specific rather than one mega-tool with a mode parameter that changes its behavior entirely.

## Use cases

### undefined



### undefined



### undefined



## Frequently asked questions

### How does an AI agent tool differ from a plain function call in my code?

The function itself is identical. What makes it a tool is the schema and description you expose to the model, which decides when and with what arguments to call it. Your code still executes it exactly like any other function call.

### Should I use @tool from the OpenAI Agents SDK or write the JSON schema by hand?

Use @tool. It derives the parameter schema from your function's type hints and the description from its docstring, which stays in sync with the code automatically. Hand-written schemas drift from the implementation over time.

### How many tools can one agent reliably use?

There's no hard limit, but tool-selection accuracy degrades as the list grows and tools overlap in purpose. A dozen well-differentiated tools usually outperforms thirty vaguely similar ones; split by risk tier and task type before you add more.

### What's the difference between a read-only tool and marking one destructive in Claude's tool annotations?

The idea is the same across SDKs, but the mechanism differs. The OpenAI Agents SDK has no built-in annotation for it, so this guide enforces the split by which tool list an Agent instance gets. The Claude Agent SDK has explicit readOnlyHint and destructiveHint annotations for the same purpose.

### Do tool results get sent back to the model verbatim?

Yes, whatever string your tool returns becomes part of the conversation the model sees next. Keep return values concise and structured; a tool that dumps a 10,000-row table back as text wastes tokens and often confuses the model more than it helps.

### Can someone design and build a tool layer like this for me?

Yes. RapidDev builds production tool layers across the OpenAI Agents SDK, Claude Agent SDK, and LangChain, including the risk-tiering and validation most tutorials skip. A free 30-minute consultation is the fastest way to scope your version.

---

Source: https://www.rapidevelopers.com/build-ai-agent/how-to-give-an-ai-agent-tools
© RapidDev — https://www.rapidevelopers.com/build-ai-agent/how-to-give-an-ai-agent-tools
