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.
| Fact | Value |
|---|---|
| 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 |
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
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.
1pip install openai-agents2export OPENAI_API_KEY="sk-..."34from agents.decorators import tool56SERVERS = {"web-01": {"disk_used_pct": 42}, "web-02": {"disk_used_pct": 91}}78@tool9def check_disk_usage(server: str) -> str:10 """Check disk usage percentage for a named server.1112 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.
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.
1from agents import RunContextWrapper2from typing import Any34def 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."67@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.1011 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.
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.
1@tool2def 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.56 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."1112SAFE_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.
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.
1from agents import Agent, Runner23helpdesk = 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)1213result = 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.
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.
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)67# Only reachable from a code path that already verified identity8result = 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.
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.
1ALLOWED_SERVERS_FOR_THIS_AGENT = {"web-01", "web-02"}23@tool4def 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
1"""IT helpdesk agent: a read-only check, a logged state change, and a2destructive 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 Any67from agents import Agent, Runner, RunContextWrapper8from agents.decorators import tool910SERVERS = {11 "web-01": {"disk_used_pct": 42},12 "web-02": {"disk_used_pct": 91},13}14ALLOWED_SERVERS = set(SERVERS)151617@tool18def check_disk_usage(server: str) -> str:19 """Check disk usage percentage for a named server.2021 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."272829def 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."313233@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.3637 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}."454647@tool48def 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.5152 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."575859SAFE_TOOLS = [check_disk_usage, restart_service]60DESTRUCTIVE_TOOLS = [reset_password]6162helpdesk = 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)7172helpdesk_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)7778if __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)84Common 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.
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]
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.
Talk to an Expert
Our team has built 1,000+ apps. Get personalized help with your project.
Book a free consultation