AI Tools
Chapter 10: Extending sparQy with Custom Tools
sparQy is sparQ's AI assistant that lives in the #agent channel. Users type natural language requests, and sparQy proposes structured actions using registered tools. This chapter shows you how to create custom tools that sparQy can use.
How It Works
When a user sends a message to the #agent channel:
- sparQ collects all registered tools from every module
- The message and tools are sent to an LLM (OpenAI or Anthropic)
- The LLM decides which tool to call based on the user's intent
- For write operations, sparQy shows a proposal card with Confirm/Edit/Cancel buttons
- For read operations (searches), results are shown immediately
Example: User types "Add a task to call John tomorrow at 2pm" → sparQy proposes create_task with title, date, and time filled in → User clicks Confirm → Task is created.
The Tool Class
Tools are defined using the Tool dataclass from system.ai:
from system.ai import Tool
my_tool = Tool(
name="create_widget", # Unique identifier
description="Create a new widget", # What the LLM sees
parameters={...}, # JSON Schema for arguments
execute=my_execute_function, # Function called when tool runs
)
Tool Properties
| Property | Type | Description |
|---|---|---|
name |
string | Unique identifier like create_task or search_contacts |
description |
string | Explains what the tool does—the LLM uses this to decide when to call it |
parameters |
dict | JSON Schema defining the tool's input parameters |
execute |
callable | Function that runs when the tool is confirmed |
Parameter Schema
Parameters use JSON Schema format. This tells the LLM what arguments the tool accepts:
parameters={
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "Title of the item (required)",
},
"priority": {
"type": "integer",
"description": "Priority level from 1-5",
},
"is_active": {
"type": "boolean",
"description": "Whether the item is active",
"default": True,
},
},
"required": ["title"], # List of required fields
}
Supported Types
string— Text valuesinteger— Whole numbersnumber— Decimal numbersboolean— True/false valuesarray— Lists of items
Good descriptions matter. The LLM uses your descriptions to understand what values to extract from the user's message. Be specific: "Date in YYYY-MM-DD format" is better than just "Date".
The Execute Function
The execute function receives a dictionary of extracted parameters and returns a result dictionary:
def _execute_create_widget(args: dict) -> dict:
"""Execute the create_widget tool."""
# Extract parameters
title = args.get("title")
priority = args.get("priority", 3)
# Validate
if not title:
return {"status": "error", "message": "Title is required"}
# Create the record
widget = Widget.create(title=title, priority=priority)
# Return result
return {
"status": "created",
"widget_id": widget.id,
"message": f"Created widget: {widget.title}",
}
Return Value
The return dictionary should include:
status— "created", "updated", "success", "error", etc.message— Human-readable result message- Entity ID — The ID of created/updated record
Registering Tools
Tools are registered using the register_ai_tools hook in your module's module.py:
# module.py
from system.module.hooks import hookimpl
class MyAppModule:
@hookimpl
def register_ai_tools(self, registry):
"""Register AI tools for this module."""
from .tools import create_widget, search_widgets
registry.register(create_widget)
registry.register(search_widgets)
Tools are typically defined in a tools/ directory:
Auto-Execute vs. Confirmation
By default, tools require user confirmation before executing. This prevents accidental writes. However, read-only tools (like searches) can execute immediately.
To mark a tool as auto-execute, add it to the AUTO_EXECUTE_TOOLS set in the AI service:
# modules/base/ai/service.py
AUTO_EXECUTE_TOOLS = {"search_contacts", "search_tasks", "search_widgets"}
Only for read operations. Never auto-execute tools that create, update, or delete data. Users should always confirm write operations.
Complete Example: Task Tool
Here's a complete example of the create_task tool:
# modules/base/service/tools/schedule.py
from datetime import date, datetime, timedelta
from typing import Any
from system.ai import Tool
from ..models.schedule import ScheduleTask
def _parse_date(date_str: str | None) -> date | None:
"""Parse flexible date formats like 'tomorrow' or 'Friday'."""
if not date_str:
return None
date_str = date_str.strip().lower()
today = date.today()
# Handle relative dates
if date_str == "today":
return today
if date_str == "tomorrow":
return today + timedelta(days=1)
# Handle day names
day_names = ["monday", "tuesday", "wednesday", "thursday",
"friday", "saturday", "sunday"]
if date_str in day_names:
target = day_names.index(date_str)
days_ahead = target - today.weekday()
if days_ahead <= 0:
days_ahead += 7
return today + timedelta(days=days_ahead)
# Try ISO format
try:
return datetime.strptime(date_str, "%Y-%m-%d").date()
except ValueError:
return None
def _execute_create_task(args: dict[str, Any]) -> dict[str, Any]:
"""Execute create_task tool."""
title = args.get("title")
if not title:
return {"status": "error", "message": "Task title is required"}
scheduled_date = _parse_date(args.get("scheduled_date"))
task = ScheduleTask.create(
title=title,
description=args.get("description"),
scheduled_date=scheduled_date,
)
date_str = scheduled_date.strftime("%B %d") if scheduled_date else "unscheduled"
return {
"status": "created",
"task_id": task.id,
"message": f"Created task: {task.title} for {date_str}",
}
# Tool definition
create_task = Tool(
name="create_task",
description="Create a new task or reminder. Use when user wants to add a task, todo, or reminder.",
parameters={
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "Title of the task (required)",
},
"description": {
"type": "string",
"description": "Additional details about the task",
},
"scheduled_date": {
"type": "string",
"description": "When the task should be done. Accepts: 'today', 'tomorrow', day names like 'Friday', or dates like '2025-01-15'",
},
},
"required": ["title"],
},
execute=_execute_create_task,
)
Register it in module.py:
# modules/base/service/module.py
from system.module.hooks import hookimpl
class ServiceModule:
@hookimpl
def register_ai_tools(self, registry):
from .tools import create_task, search_tasks
registry.register(create_task)
registry.register(search_tasks)
Commands
In addition to tools, the #agent channel supports commands that start with /:
/help— Show available commands/clear— Clear conversation history
Commands are handled before the message reaches the LLM. They're useful for system operations.
Adding Custom Commands
Use the @command decorator in modules/base/ai/commands.py:
from modules.base.ai.commands import command, CommandResult
@command("stats", "Show usage statistics")
def cmd_stats(args: str, channel, user) -> CommandResult:
"""Show stats for the current user."""
count = get_task_count(user.id)
return CommandResult(
success=True,
message=f"You have {count} tasks.",
)
LLM Configuration
sparQy supports multiple LLM providers. Set the provider via environment variable:
# .env
LLM_PROVIDER=openai # Default
OPENAI_API_KEY=sk-...
# Or use Anthropic
LLM_PROVIDER=anthropic
ANTHROPIC_API_KEY=sk-ant-...
Best Practices
Tool Design
- Clear descriptions — Help the LLM understand when to use your tool
- Flexible parsing — Accept multiple input formats (dates, times)
- Informative messages — Return helpful status messages
- Handle errors gracefully — Return error status with clear message
Parameter Design
- Minimal required fields — Only require what's truly necessary
- Smart defaults — Provide sensible defaults for optional fields
- Descriptive help — Use description to explain expected formats
Naming Conventions
- Use
create_*for tools that create records - Use
update_*for tools that modify records - Use
search_*for tools that query records - Use
delete_*for tools that remove records (use sparingly)
Troubleshooting
Tool not appearing
- Check that
register_ai_toolshook is decorated with@hookimpl - Verify the tool is exported in
tools/__init__.py - Check the Flask console for import errors
LLM not calling tool
- Improve the tool's description to better match user intent
- Check parameter descriptions are clear
- Verify the tool is registered (check logs on startup)
Wrong parameters extracted
- Add more specific descriptions to parameters
- Provide example formats in descriptions
- Add validation in your execute function
Key Takeaways
- Tools let sparQy perform actions based on natural language
- Define tools using the
Toolclass with name, description, parameters, and execute function - Register tools via the
register_ai_toolshook inmodule.py - Read-only tools can auto-execute; write tools require confirmation
- Good descriptions help the LLM choose the right tool