Skip to main content
RapidDev - Software Development Agency
build-ai-agent

How to Give an AI Agent Tools

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 learn

  • Turn a typed Python function into a tool the model can call, using a docstring as the tool's description
  • Write a failure_error_function so a tool that raises gives the model something useful instead of a crash
  • Separate read-only tools from state-changing ones at the design stage, before you need an approval layer
  • Decide when a task needs a new tool versus when it needs a better prompt around an existing one
Book a free consultation
4.9Clutch rating ⭐
1,000+Happy partners
20+Countries served
200+Team members
Intermediate10 min read30-45 minPython 3.10+, openai-agents 0.24+, an OpenAI API key, any OSLast updated November 2026RapidDev Engineering Team
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.

Quick facts about this guide
FactValue
ToolAny LLM or framework
DifficultyIntermediate
Time required30-45 min
CompatibilityPython 3.10+, openai-agents 0.24+, an OpenAI API key, any OS
Last updatedNovember 2026

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.

Prerequisites

  • 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.

typescript
1pip install openai-agents
2export OPENAI_API_KEY="sk-..."
3
4from agents.decorators import tool
5
6SERVERS = {"web-01": {"disk_used_pct": 42}, "web-02": {"disk_used_pct": 91}}
7
8@tool
9def check_disk_usage(server: str) -> str:
10 """Check disk usage percentage for a named server.
11
12 Args:
13 server: The server hostname, e.g. 'web-01'.
14 """
15 info = SERVERS.get(server)
16 if not info:
17 return f"No server named {server}. Known servers: {list(SERVERS)}"
18 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.

typescript
1from agents import RunContextWrapper
2from typing import Any
3
4def friendly_restart_error(ctx: RunContextWrapper[Any], error: Exception) -> str:
5 return f"Restart failed: {error}. Try a different service name or escalate to on-call."
6
7@tool(failure_error_function=friendly_restart_error)
8def restart_service(server: str, service: str) -> str:
9 """Restart a named service on a server. Logs the action.
10
11 Args:
12 server: The server hostname, e.g. 'web-01'.
13 service: The service name, e.g. 'nginx' or 'worker'.
14 """
15 if server not in SERVERS:
16 raise ValueError(f"Unknown server: {server}")
17 print(f"[AUDIT] restart_service({server}, {service})")
18 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.

typescript
1@tool
2def reset_password(user_email: str) -> str:
3 """Reset a user's password and email them a temporary one.
4 Destructive: locks the user out of their current password immediately.
5
6 Args:
7 user_email: The user's email address.
8 """
9 print(f"[AUDIT] reset_password({user_email})")
10 return f"Password reset for {user_email}. Temporary password emailed."
11
12SAFE_TOOLS = [check_disk_usage, restart_service]
13DESTRUCTIVE_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.

typescript
1from agents import Agent, Runner
2
3helpdesk = Agent(
4 name="IT Helpdesk",
5 instructions=(
6 "You help with server operations. You can check disk usage and restart "
7 "services freely. You do not have access to password resets; tell the "
8 "user to escalate those to a human."
9 ),
10 tools=SAFE_TOOLS,
11)
12
13result = Runner.run_sync(helpdesk, "web-02 feels slow, can you check it and restart nginx if disk is over 90%?")
14print(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.

typescript
1helpdesk_privileged = Agent(
2 name="IT Helpdesk (privileged)",
3 instructions=helpdesk.instructions + " You may reset passwords after identity is confirmed.",
4 tools=SAFE_TOOLS + DESTRUCTIVE_TOOLS,
5)
6
7# Only reachable from a code path that already verified identity
8result = 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.

typescript
1ALLOWED_SERVERS_FOR_THIS_AGENT = {"web-01", "web-02"}
2
3@tool
4def check_disk_usage(server: str) -> str:
5 """Check disk usage percentage for a named server."""
6 if server not in ALLOWED_SERVERS_FOR_THIS_AGENT:
7 return f"{server} is outside this agent's scope. Allowed: {ALLOWED_SERVERS_FOR_THIS_AGENT}"
8 info = SERVERS.get(server, {"disk_used_pct": 0})
9 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 working example

it_helpdesk_agent.py
1"""IT helpdesk agent: a read-only check, a logged state change, and a
2destructive tool kept in a separate, opt-in tool list.
3Run: python it_helpdesk_agent.py (pip install openai-agents; set OPENAI_API_KEY)
4"""
5from typing import Any
6
7from agents import Agent, Runner, RunContextWrapper
8from agents.decorators import tool
9
10SERVERS = {
11 "web-01": {"disk_used_pct": 42},
12 "web-02": {"disk_used_pct": 91},
13}
14ALLOWED_SERVERS = set(SERVERS)
15
16
17@tool
18def check_disk_usage(server: str) -> str:
19 """Check disk usage percentage for a named server.
20
21 Args:
22 server: The server hostname, e.g. 'web-01'.
23 """
24 if server not in ALLOWED_SERVERS:
25 return f"{server} is outside this agent's scope. Allowed: {ALLOWED_SERVERS}"
26 return f"{server} is at {SERVERS[server]['disk_used_pct']}% disk usage."
27
28
29def friendly_restart_error(ctx: RunContextWrapper[Any], error: Exception) -> str:
30 return f"Restart failed: {error}. Try a different service name or escalate to on-call."
31
32
33@tool(failure_error_function=friendly_restart_error)
34def restart_service(server: str, service: str) -> str:
35 """Restart a named service on a server. Logs the action.
36
37 Args:
38 server: The server hostname, e.g. 'web-01'.
39 service: The service name, e.g. 'nginx' or 'worker'.
40 """
41 if server not in SERVERS:
42 raise ValueError(f"Unknown server: {server}")
43 print(f"[AUDIT] restart_service({server}, {service})")
44 return f"Restarted {service} on {server}."
45
46
47@tool
48def reset_password(user_email: str) -> str:
49 """Reset a user's password and email them a temporary one.
50 Destructive: locks the user out of their current password immediately.
51
52 Args:
53 user_email: The user's email address.
54 """
55 print(f"[AUDIT] reset_password({user_email})")
56 return f"Password reset for {user_email}. Temporary password emailed."
57
58
59SAFE_TOOLS = [check_disk_usage, restart_service]
60DESTRUCTIVE_TOOLS = [reset_password]
61
62helpdesk = Agent(
63 name="IT Helpdesk",
64 instructions=(
65 "You help with server operations. You can check disk usage and restart "
66 "services freely. You do not have access to password resets; tell the "
67 "user to escalate those to a human."
68 ),
69 tools=SAFE_TOOLS,
70)
71
72helpdesk_privileged = Agent(
73 name="IT Helpdesk (privileged)",
74 instructions=helpdesk.instructions + " You may reset passwords after identity is confirmed.",
75 tools=SAFE_TOOLS + DESTRUCTIVE_TOOLS,
76)
77
78if __name__ == "__main__":
79 result = Runner.run_sync(
80 helpdesk,
81 "web-02 feels slow, can you check it and restart nginx if disk is over 90%?",
82 )
83 print(result.final_output)
84

Common mistakes

Why it's a problem: Writing a one-line docstring like 'restarts a service' with no argument guidance

How to avoid: 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.

Why it's a problem: Putting a destructive tool in the same tools list as read-only ones out of convenience

How to avoid: Split tool lists by risk from the start. Adding an approval layer later is much harder if every agent instance already has blanket access.

Why it's a problem: Letting a tool raise an uncaught exception with no failure_error_function

How to avoid: The default error message is generic. Write a failure_error_function that tells the model what went wrong and what to try instead.

Why it's a problem: Trusting the type hint alone to constrain what the model passes

How to avoid: 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.

Why it's a problem: Creating a new tool for every minor variation of the same action

How to avoid: 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.

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

I have an OpenAI Agents SDK agent with check_disk_usage, restart_service, and a separate reset_password tool kept out of the default tool list (code below). Add a fourth tool, scale_service, that changes replica count, give it the same failure_error_function pattern as restart_service, and keep it in SAFE_TOOLS. [paste complete_code]

Build AI Agents Prompt

In Claude Code, open it_helpdesk_agent.py and ask: 'Add input validation to restart_service so it rejects any service name not in an explicit allowlist, the same way check_disk_usage validates server names.'

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.

RapidDev

Talk to an Expert

Our team has built 1,000+ apps. Get personalized help with your project.

Book a free consultation
Matt Graham

Written by

Matt Graham · CEO & Founder, RapidDev

1,000+ client projects delivered. Columbia University & Harvard Business School alumnus, U.S. Navy veteran. About the author →

Learning is great. Shipping is faster with help.

Our engineers have built 1,000+ apps on your platform and the tools around it. If your project needs to be live sooner than your learning curve allows — book a free consultation.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.