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:

  1. sparQ collects all registered tools from every module
  2. The message and tools are sent to an LLM (OpenAI or Anthropic)
  3. The LLM decides which tool to call based on the user's intent
  4. For write operations, sparQy shows a proposal card with Confirm/Edit/Cancel buttons
  5. 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

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:

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:

myapp/ ├── __init__.py ├── __manifest__.py ├── module.py ├── tools/ │ ├── __init__.py │ └── widgets.py # Tool definitions └── ...

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 /:

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

Parameter Design

Naming Conventions

Troubleshooting

Tool not appearing

  1. Check that register_ai_tools hook is decorated with @hookimpl
  2. Verify the tool is exported in tools/__init__.py
  3. Check the Flask console for import errors

LLM not calling tool

  1. Improve the tool's description to better match user intent
  2. Check parameter descriptions are clear
  3. Verify the tool is registered (check logs on startup)

Wrong parameters extracted

  1. Add more specific descriptions to parameters
  2. Provide example formats in descriptions
  3. Add validation in your execute function

Key Takeaways