Developer Guide

Developer Guide 4: Extending & Publishing

Extending with Hooks

Chapter 9: Hooking into sparQ's lifecycle to customize behavior.

Hooks let your app run code at specific points in sparQ's lifecycle—when the app starts, when the database initializes, or when certain events occur. They're essential for plugins and useful for apps that need to set up data or configuration.

The module.py File

Hooks are defined in your app's module.py file:

myapp/ ├── __init__.py ├── __manifest__.py ├── module.py # Define hooks here └── ...

When sparQ loads your module, it looks for specific function names in this file and calls them at the right time.

Available Hooks

init_app(app)

Called when the Flask application initializes. The app parameter is the Flask application instance.

# module.py
def init_app(app):
    """Called when Flask app initializes."""
    # Register custom configuration
    app.config['MYAPP_FEATURE_ENABLED'] = True

    # Register a custom error handler
    @app.errorhandler(CustomError)
    def handle_custom_error(e):
        return render_template('myapp/desktop/error.html', error=e), 500

    # Register a template filter
    @app.template_filter('reverse')
    def reverse_filter(s):
        return s[::-1]

Use this hook for:

  • Setting configuration values
  • Registering error handlers
  • Adding template filters or globals
  • Setting up third-party extensions

init_database(db)

Called after database tables are created. The db parameter is the SQLAlchemy database instance.

# module.py
def init_database(db):
    """Called after db.create_all()."""
    from .models.category import Category

    # Seed default data if table is empty
    if Category.query.count() == 0:
        defaults = ['Work', 'Personal', 'Shopping', 'Health']
        for name in defaults:
            Category.create(name=name)
        print("Created default categories")

Use this hook for:

  • Seeding default data
  • Creating required records
  • Running data migrations

Check before inserting. Always check if data exists before inserting. This hook runs every time sparQ starts, not just on first install.

Practical Examples

Setting Up Default Settings

# module.py
def init_database(db):
    """Create default settings for new installations."""
    from .models.setting import Setting

    defaults = {
        'notifications_enabled': 'true',
        'items_per_page': '20',
        'theme': 'light'
    }

    for key, value in defaults.items():
        if not Setting.get(key):
            Setting.create(key=key, value=value)

Registering Custom Jinja Globals

# module.py
def init_app(app):
    """Add custom functions available in all templates."""
    from .models.category import Category

    @app.context_processor
    def inject_categories():
        # Now {{ categories }} is available in all templates
        return {'categories': Category.get_all()}

Adding Request Hooks

# module.py
def init_app(app):
    """Run code before/after each request."""

    @app.before_request
    def log_request():
        # Log every request (be careful with performance)
        app.logger.debug(f"Request: {request.path}")

    @app.after_request
    def add_header(response):
        # Add custom headers to every response
        response.headers['X-MyApp-Version'] = '1.0.0'
        return response

Integrating Third-Party Services

# module.py
def init_app(app):
    """Set up external service connections."""
    import redis

    # Create Redis connection
    app.redis = redis.from_url(app.config.get('REDIS_URL', 'redis://localhost'))

    # Now accessible as current_app.redis in controllers

Hook Execution Order

When sparQ starts, hooks execute in this order:

  1. Core module init_app()
  2. Dashboard module init_app()
  3. Team module init_app()
  4. All other base modules init_app() (alphabetically)
  5. All apps init_app() (alphabetically)
  6. Database tables created
  7. Core module init_database()
  8. Dashboard module init_database()
  9. Team module init_database()
  10. All other base modules init_database() (alphabetically)
  11. All apps init_database() (alphabetically)

This order matters if your app depends on another module's setup.

register_ai_tools(registry)

Called during tool collection for the AI agent. The registry parameter is a ToolRegistry instance where you register your tools.

# 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)

Use this hook for:

  • Registering tools that sparQy can use
  • Making your module's features accessible via natural language

See the AI Tools guide for complete documentation on creating tools.

Plugin Hooks

Plugins can hook into additional events using the pluggy system:

# module.py
import pluggy

hookimpl = pluggy.HookimplMarker("sparq")

@hookimpl
def on_user_created(user):
    """Called when a new user is created."""
    # Send welcome email, create default data, etc.
    send_welcome_email(user.email)

@hookimpl
def on_invoice_paid(invoice):
    """Called when an invoice is marked as paid."""
    # Update inventory, send receipt, etc.
    update_inventory(invoice.items)

Available plugin hooks depend on which base modules are installed. Check each module's documentation for available hooks.

Key Takeaways

  • Define hooks in module.py
  • init_app() runs when Flask initializes—use for configuration
  • init_database() runs after tables are created—use for seeding data
  • Always check if data exists before inserting
  • Hooks run in module load order

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

  • string — Text values
  • integer — Whole numbers
  • number — Decimal numbers
  • boolean — True/false values
  • array — 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:

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

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

  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

  • Tools let sparQy perform actions based on natural language
  • Define tools using the Tool class with name, description, parameters, and execute function
  • Register tools via the register_ai_tools hook in module.py
  • Read-only tools can auto-execute; write tools require confirmation
  • Good descriptions help the LLM choose the right tool

Publishing to Marketplace

Chapter 11: Sharing your app with the sparQ community.

You've built an app and it works great. Now let's package it up and share it with other sparQ users through the marketplace.

Before You Publish

Make sure your app is ready:

  • Test thoroughly - Works in fresh sparQ installs
  • Complete manifest - All required fields filled in, including mappid
  • Good translations - At least English is complete
  • Clear documentation - Users know what your app does
  • Nice icon - SVG or PNG that looks good at small sizes
Already have a mappid? If you scaffolded your app with make app name=myapp in the SDK, your mappid was auto-generated. Check your __manifest__.py to confirm.

The marketplace.json File

Create a marketplace.json file in your app folder with metadata for the marketplace listing:

{
    "marketplace_id": "task-manager",
    "name": "Task Manager",
    "type": "app",
    "author": "Your Name",
    "author_email": "you@example.com",
    "description": "A simple but powerful task management app.",
    "long_description": "Track tasks, set due dates, organize with categories, and never miss a deadline. Perfect for personal productivity or small teams.",
    "category": "productivity",
    "tags": ["tasks", "productivity", "todo", "gtd"],
    "homepage": "https://github.com/yourname/task-manager",
    "license": "MIT",
    "min_sparq_version": "1.0.0",
    "screenshots": [
        "screenshots/list-view.png",
        "screenshots/detail-view.png"
    ]
}
No version field needed! The SDK automatically injects the version from your __manifest__.py when you run make archive or make release. This keeps your version in sync automatically.

Required Fields

Field Description
marketplace_id Unique identifier (lowercase, hyphens only)
name Display name
type "app", "plugin", or "lang_pack"
author Your name or organization
author_email Contact email

Optional Fields

Field Description
description Short description (one sentence)
long_description Detailed description for the listing page
category productivity, sales, hr, finance, etc.
tags Keywords for search
homepage Link to documentation or repository
license MIT, Apache-2.0, proprietary, etc.
min_sparq_version Minimum sparQ version required
screenshots Array of screenshot paths

Add an Icon

Your app needs an icon. Add it as icon.svg or icon.png in your app folder:

myapp/ ├── __manifest__.py ├── marketplace.json ├── icon.svg # App icon └── ...

Icon guidelines:

  • SVG preferred (scales perfectly)
  • If PNG, at least 256x256 pixels
  • Simple, recognizable at small sizes
  • Works on both light and dark backgrounds

Add Screenshots

Screenshots help users understand what they're getting:

myapp/ ├── screenshots/ │ ├── list-view.png │ ├── detail-view.png │ └── settings.png └── ...

Screenshot tips:

  • Show the most important features
  • Use realistic sample data
  • Keep them up to date with your UI
  • 1280x720 or larger recommended

Create the Release Package

Use the SDK's release command to create a distributable ZIP file:

cd sdk
make release name=myapp

This creates myapp-1.0.zip with everything needed:

myapp-1.0.zip ├── app/ │ ├── __init__.py │ ├── __manifest__.py │ ├── module.py │ ├── controllers/ │ ├── models/ │ ├── views/ │ └── lang/ ├── marketplace.json ├── icon.svg └── screenshots/

Submit to the Marketplace

  1. Go to marketplace.sparqone.com
  2. Sign in or create a developer account
  3. Click "Submit New App"
  4. Upload your ZIP file
  5. Review the auto-populated listing information
  6. Submit for review
Image: Marketplace submission form

The Review Process

After submission, your app goes through review:

  1. Automated checks - Manifest validation, security scan
  2. Manual review - A human checks functionality and quality
  3. Approval or feedback - Usually within 2-3 business days

Common Rejection Reasons

  • Missing or incomplete manifest fields
  • App crashes on fresh install
  • Security issues (SQL injection, XSS, etc.)
  • Poor quality icon or screenshots
  • Description doesn't match functionality

Updating Your App

To release an update:

  1. Bump the version in __manifest__.py
  2. Run cd sdk && make release name=myapp
  3. Go to your app's dashboard on the marketplace
  4. Click "Submit Update"
  5. Upload the new ZIP

The SDK automatically syncs the version to marketplace.json when packaging. Updates go through a lighter review process—usually approved same day if there are no major changes.

Version Numbering

Use simple MAJOR.MINOR versioning:

  • 1.0 → 1.1 - New features or bug fixes
  • 1.1 → 1.2 - More updates
  • 1.9 → 2.0 - Breaking changes or major rewrite

Key Takeaways

  • Ensure your manifest has a mappid (auto-generated by the SDK)
  • Create marketplace.json with your listing metadata (no version field needed)
  • Set version only in __manifest__.py—the SDK syncs it automatically
  • Include a good icon and screenshots
  • Use cd sdk && make release to create the ZIP package
  • Submit through the marketplace website