Creating Custom Tools
Custom tools let you extend agent capabilities beyond built-in Frappe operations. Link your own Python functions to agents and unlock unlimited possibilities.
What Are Custom Tools?
Custom tools are your own Python functions that agents can call. They enable agents to perform specialized tasks that built-in tools don’t cover.
If built-in tools are the standard toolkit, custom tools are your custom-made specialty tools.
When to Create Custom Tools
Choose the right approach based on whether your task needs custom logic or can use standard operations.
Create custom tools when you need to:
Use built-in tools when:
How Custom Tools Work
Custom tools bridge your Python code and Huf agents through a defined lifecycle.
The Flow
The agent discovers and executes your function through these stages.
Function Requirements
Your Python function must meet these baseline requirements to be callable by an agent.
Creating a Custom Tool
Build and connect a custom tool in four stages.
Create a function in your Frappe app:
File: my_app/api/tools.py
import frappe
def calculate_customer_lifetime_value(customer_id):
"""
Calculate the total lifetime value for a customer.
Args:
customer_id (str): The customer ID
Returns:
dict: Customer LTV details including total value and order count
"""
# Query all sales invoices for this customer
invoices = frappe.get_all(
"Sales Invoice",
filters={"customer": customer_id, "docstatus": 1},
fields=["grand_total"]
)
# Calculate totals
total_value = sum(inv.grand_total for inv in invoices)
order_count = len(invoices)
# Calculate average order value
avg_order = total_value / order_count if order_count > 0 else 0
return {
"customer_id": customer_id,
"lifetime_value": total_value,
"order_count": order_count,
"average_order_value": avg_order
}Key Points:
Create the Agent Tool Function record that points to your Python function.
Navigate to: Desk → Huf → Agent Tool Function
Click New and fill in:
Guidelines:
- Use descriptive snake_case names
- Should match function name (not required but clearer)
- Be specific—agent uses this to decide when to use the tool
- Mention what data it returns
- Include any limitations
- Full dotted path to the function must be importable:
from my_app.api.tools import calculate_customer_lifetime_value
Save the tool
Link the tool to the agent that should be able to use it.
- Open your agent (Desk → Huf → Agent)
- Scroll to Agent Tool table
- Click Add Row
- Select Tool Function:
calculate_customer_ltv - Save the agent
Use Agent Chat or run the agent:
Parameter Types
When defining parameters in Agent Tool Function, map each type to its Python equivalent.
| Type | Python Type | Description | Example |
|---|---|---|---|
| String | str | Text values | "CUST-001", "high" |
| Int | int | Integers | 42, 100, -5 |
| Float | float | Decimals | 3.14, 99.99 |
| Boolean | bool | True/False | True, False |
| Object | dict | JSON objects | {"key": "value"} |
| Array | list | JSON arrays | ["item1", "item2"] |
These signatures show how to declare parameters for each type:
def search_products(category: str, min_price: float, in_stock: bool):
"""Search products with filters."""
pass
def batch_process_orders(order_ids: list):
"""Process multiple orders at once."""
pass
def create_customer_profile(data: dict):
"""Create customer with complex nested data."""
passAdvanced Examples
These examples show common custom tool patterns.
Example 1: External API Integration
Use this pattern when you need to call an external API from an agent.
import requests
import frappe
def get_weather_forecast(city: str):
"""
Get weather forecast for a city from OpenWeather API.
Args:
city (str): City name
Returns:
dict: Weather forecast data
"""
api_key = frappe.conf.get("openweather_api_key")
response = requests.get(
f"https://api.openweathermap.org/data/2.5/weather",
params={"q": city, "appid": api_key, "units": "metric"}
)
if response.status_code == 200:
data = response.json()
return {
"city": city,
"temperature": data["main"]["temp"],
"condition": data["weather"][0]["description"],
"humidity": data["main"]["humidity"]
}
else:
frappe.throw(f"Weather API error: {response.status_code}")When user asks about weather:
- Extract city name
- Use get_weather_forecast tool
- Present temperature, condition, and humidity in a friendly format
Example 2: Complex Calculation
Use this pattern to run calculations over Frappe data.
import frappe
from datetime import datetime, timedelta
def calculate_reorder_quantity(item_code: str, days_ahead: int = 30):
"""
Calculate optimal reorder quantity based on historical consumption.
Args:
item_code (str): Item code
days_ahead (int): Days to plan ahead (default: 30)
Returns:
dict: Reorder recommendations
"""
# Get current stock
stock_qty = frappe.db.get_value("Bin",
{"item_code": item_code},
"actual_qty"
) or 0
# Get item details
item = frappe.get_doc("Item", item_code)
# Calculate average daily consumption (last 90 days)
ninety_days_ago = datetime.now() - timedelta(days=90)
consumed = frappe.db.sql("""
SELECT SUM(actual_qty)
FROM `tabStock Ledger Entry`
WHERE item_code = %s
AND posting_date >= %s
AND actual_qty < 0
""", (item_code, ninety_days_ago))[0][0] or 0
daily_consumption = abs(consumed) / 90
# Calculate needed quantity
needed_qty = daily_consumption * days_ahead
reorder_qty = max(0, needed_qty - stock_qty)
# Round up to nearest safety stock level
if item.safety_stock:
reorder_qty = max(reorder_qty, item.safety_stock)
return {
"item_code": item_code,
"current_stock": stock_qty,
"daily_consumption": round(daily_consumption, 2),
"days_ahead": days_ahead,
"recommended_reorder_qty": round(reorder_qty, 2),
"estimated_stockout_days": round(stock_qty / daily_consumption if daily_consumption > 0 else 999, 1)
}Example 3: Validation Logic
Use this pattern to validate documents and flag issues.
import frappe
def validate_invoice_pricing(invoice_name: str):
"""
Validate invoice pricing against price list and flag anomalies.
Args:
invoice_name (str): Sales Invoice name
Returns:
dict: Validation results with any issues found
"""
invoice = frappe.get_doc("Sales Invoice", invoice_name)
issues = []
for item in invoice.items:
# Get price list rate
price_list_rate = frappe.db.get_value(
"Item Price",
{
"item_code": item.item_code,
"price_list": invoice.selling_price_list
},
"price_list_rate"
)
if not price_list_rate:
issues.append({
"item": item.item_code,
"issue": "No price list rate found",
"severity": "high"
})
continue
# Check for significant variance
variance_pct = ((item.rate - price_list_rate) / price_list_rate) * 100
if abs(variance_pct) > 10:
issues.append({
"item": item.item_code,
"issue": f"Price varies {variance_pct:.1f}% from price list",
"expected": price_list_rate,
"actual": item.rate,
"severity": "medium" if abs(variance_pct) < 20 else "high"
})
return {
"invoice": invoice_name,
"is_valid": len(issues) == 0,
"issues_found": len(issues),
"issues": issues
}Best Practices
Follow these guidelines to build reliable, maintainable custom tools.
Function Design
Well-designed functions are easier for agents to understand and safer to maintain.
- Write clear, focused functions (single responsibility)
- Use descriptive function and parameter names
- Include comprehensive docstrings
- Return structured data (dicts with clear keys)
- Handle errors and edge cases
- Add type hints for parameters
- Create mega-functions that do everything
- Use vague parameter names (`data`, `info`, `x`)
- Return raw database objects (serialize them)
- Forget error handling
- Make functions depend on global state
- Use side effects without documenting them
Error Handling
Handle errors gracefully so agents can report meaningful failures.
def get_customer_balance(customer_id: str):
"""Get customer outstanding balance."""
# Validate input
if not customer_id:
frappe.throw("Customer ID is required")
# Check if customer exists
if not frappe.db.exists("Customer", customer_id):
frappe.throw(f"Customer {customer_id} not found")
try:
balance = frappe.db.get_value("Customer", customer_id, "outstanding_amount")
return {"customer_id": customer_id, "balance": balance or 0}
except Exception as e:
frappe.log_error(f"Error getting balance for {customer_id}: {str(e)}")
frappe.throw(f"Failed to retrieve customer balance: {str(e)}")Permissions
Respect Frappe permissions explicitly when your tool performs sensitive actions.
def delete_customer_document(doc_name: str):
"""Delete a customer (respects permissions)."""
# Check if user has permission
if not frappe.has_permission("Customer", "delete", doc_name):
frappe.throw("You don't have permission to delete this customer")
# Verify document exists
if not frappe.db.exists("Customer", doc_name):
frappe.throw(f"Customer {doc_name} not found")
# Delete
frappe.delete_doc("Customer", doc_name)
return {"success": True, "message": f"Customer {doc_name} deleted"}frappe.has_permission()Performance
Optimize your tool to minimize database round-trips and data transfer.
# BAD: Multiple queries in loop
def get_customer_orders_slow(customer_id):
orders = frappe.get_all("Sales Order", {"customer": customer_id}, ["name"])
results = []
for order in orders:
doc = frappe.get_doc("Sales Order", order.name) # Separate query each time
results.append(doc.as_dict())
return results
# GOOD: Single query with all data
def get_customer_orders_fast(customer_id):
orders = frappe.get_all(
"Sales Order",
filters={"customer": customer_id},
fields=["name", "grand_total", "status", "delivery_date"]
)
return {"customer_id": customer_id, "orders": orders}frappe.get_all() instead of multiple get_doc()Debugging Custom Tools
Use these techniques to verify and troubleshoot your custom tools.
Test in Frappe Console
Test your function directly in the Frappe console before wiring it to an agent.
bench --site yoursite consoleOnce inside, import and run your function:
>>> from my_app.api.tools import calculate_customer_ltv
>>> result = calculate_customer_ltv("CUST-001")
>>> print(result)Check Agent Run Logs
Review the Agent Run logs after an agent uses your tool.
Common Issues
These issues and fixes cover the most common custom tool problems.
| Issue | Cause / Fix |
|---|---|
| ”Function not found” | Verify function path is correct. Check function is importable. Restart bench after adding new functions. |
| ”Missing parameter” | Agent didn’t provide required parameter. Improve tool description to clarify what’s needed. Check agent instructions mention parameter. |
| ”TypeError” or “JSON serialization error” | Return value isn’t JSON-serializable. Convert objects to dicts. Convert dates to strings. Don’t return Frappe DocType objects directly. |
| Function never called | Tool description may be unclear. Agent doesn’t recognize when to use it. Try improving description or agent instructions. |
Documentation Tips
Clear documentation helps agents choose and use your tools correctly.
Writing Great Descriptions
Compare a vague description with one that gives the agent clear context.
Description: Handles customer calculations→ Too vague, agent won’t know when to use it
Description: Calculate the total lifetime value for a customer including
all paid invoices, order count, and average order value. Use this when
users ask about customer value, spending history, or order statistics.→ Clear purpose, context, and use cases
Include Examples in Docstrings
Add concrete usage examples to your docstrings so agents understand expected inputs and outputs.
def convert_currency(amount: float, from_currency: str, to_currency: str):
"""
Convert an amount from one currency to another using current rates.
Args:
amount (float): Amount to convert
from_currency (str): Source currency code (USD, EUR, GBP, etc.)
to_currency (str): Target currency code
Returns:
dict: Converted amount and exchange rate used
Example:
>>> convert_currency(100, "USD", "EUR")
{"amount": 100, "from": "USD", "to": "EUR", "converted": 85.50, "rate": 0.855}
"""
# Implementation
passThe agent can “read” this docstring to understand how to use the tool.
Security Considerations
Keep these security principles in mind when building custom tools.
Dangerous Operations
Be especially careful when tools can access sensitive resources or execute side effects.
Be careful with operations that can damage data or leak information.
Input Validation
Validate all inputs before executing sensitive operations.
def send_email_notification(recipient: str, subject: str, message: str):
"""Send email (with validation)."""
# Validate email format
if not frappe.utils.validate_email_address(recipient):
frappe.throw(f"Invalid email address: {recipient}")
# Whitelist allowed recipients (optional but safer)
allowed_domains = ["yourcompany.com", "partner.com"]
domain = recipient.split("@")[1]
if domain not in allowed_domains:
frappe.throw(f"Can only send to {', '.join(allowed_domains)} addresses")
# Send email
frappe.sendmail(recipients=[recipient], subject=subject, message=message)
return {"success": True, "recipient": recipient}What’s Next?
Explore related topics to continue building with custom tools.
- Publishing Tools - Share tools via apps
- Built-in Tools - Reference for built-in tools
- Tools Concept - Tool fundamentals
- Creating Agents - Assign custom tools to agents
Questions? Visit GitHub discussions .