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:
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:
- Core module
init_app() - Dashboard module
init_app() - Team module
init_app() - All other base modules
init_app()(alphabetically) - All apps
init_app()(alphabetically) - Database tables created
- Core module
init_database() - Dashboard module
init_database() - Team module
init_database() - All other base modules
init_database()(alphabetically) - 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 configurationinit_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:
- 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
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
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"
]
}
__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:
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:
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:
Submit to the Marketplace
- Go to marketplace.sparqone.com
- Sign in or create a developer account
- Click "Submit New App"
- Upload your ZIP file
- Review the auto-populated listing information
- Submit for review
The Review Process
After submission, your app goes through review:
- Automated checks - Manifest validation, security scan
- Manual review - A human checks functionality and quality
- 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:
- Bump the version in
__manifest__.py - Run
cd sdk && make release name=myapp - Go to your app's dashboard on the marketplace
- Click "Submit Update"
- 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.jsonwith 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 releaseto create the ZIP package - Submit through the marketplace website