Daemons
Written by ddxfish on .
Daemons are long-running background processes that listen for external events and feed them into Sapphire's task scheduler. When an event arrives (new Telegram message, incoming email, etc.), the daemon emits it and the scheduler fires any matching tasks.
How It Works
Daemon thread (your code)
→ listens for external events (polling, websocket, etc.)
→ calls plugin_loader.emit_daemon_event(source_name, json_payload)
→ scheduler finds tasks matching that source
→ task executes (LLM chat with event data as context)
→ if auto-reply: reply_handler routes response back to source
Manifest Declaration
{
"capabilities": {
"daemon": {
"entry": "daemon.py",
"event_sources": [
{
"name": "my_event",
"label": "My Event",
"description": "When something happens in my service",
"filter_fields": [
{"key": "sender", "label": "Sender Name"},
{"key": "channel", "label": "Channel"}
],
"task_fields": [
{
"key": "account",
"type": "select",
"label": "Account",
"required": true,
"dynamic": "/api/plugin/my-plugin/accounts",
"help": "Which account to monitor"
},
{
"key": "auto_reply",
"type": "boolean",
"label": "Auto-reply",
"default": false
}
]
}
]
}
}
}
Manifest Fields
| Field | Type | Required | Description |
|---|---|---|---|
entry |
string | Yes | Path to daemon module (relative to plugin dir) |
event_sources |
array | No | Event types this daemon can emit |
Event Source Fields
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | Yes | Unique event identifier (used in emit_daemon_event) |
label |
string | Yes | Human-readable name (shown in the Triggers UI) |
description |
string | No | Tooltip/help text |
filter_fields |
array | No | Fields users can filter on when creating tasks |
task_fields |
array | No | Per-task configuration fields shown in the Triggers UI |
realtime |
bool | No | Gate-only source — its events never fire one-shot tasks (see Realtime sources) |
Filter Fields
Filter fields let users narrow which events trigger their task. Each filter is matched against the event payload JSON.
{"key": "username", "label": "Username"}
The scheduler ANDs all filters — every filter must match for the task to fire.
Task Fields
Task fields are per-task configuration shown in the Triggers UI (Daemons) when creating a daemon task. They support these types:
| Type | Description | Extra Properties |
|---|---|---|
select |
Dropdown | options (static — strings or {value, label} objects) or dynamic (API URL for async loading) |
boolean |
Toggle | default |
number |
Number input | min, max, default |
string |
Text input (the default for any other type) | default, placeholder; add "widget": "password" to mask the input |
Every field also takes key, label, required (marks the field required), and help (rendered as a tooltip).
The dynamic property fetches options from an API endpoint at render time — useful for account lists that change.
Realtime sources (gate-only)
Declare "realtime": true on an event source when your daemon handles
sessions live (a voice line, a persistent socket) rather than handing
discrete events to the scheduler. For a realtime source, tasks are gates,
not triggers: emit_daemon_event() deliberately fires nothing — a one-shot
task would double-handle on top of your live session. Instead, your daemon
reads its matching tasks to decide whether and how to answer:
plugin_loader.active_daemon_accounts(source)— which accounts are armed.plugin_loader.get_enabled_daemon_task(source, account, payload)— the winning task's config (chat target, persona, toolset). With apayload(e.g.{"caller": "+1555..."}), selection is most-specific-wins: a task whose filter matches beats the no-filter catch-all; a failing filter excludes the task; no match returnsNoneand the daemon should decline the session.
Daemon Module
Your daemon.py must export start() and stop():
# daemon.py — Minimal daemon skeleton
import logging
import threading
logger = logging.getLogger(__name__)
_thread = None
_stop_event = threading.Event()
_plugin_loader = None
def start(plugin_loader, settings):
"""Called by plugin_loader when the plugin loads."""
global _thread, _plugin_loader
_plugin_loader = plugin_loader
_stop_event.clear()
_thread = threading.Thread(target=_run, daemon=True, name="my-daemon")
_thread.start()
# Register reply handler if you support auto-reply
plugin_loader.register_reply_handler("my-plugin", _reply_handler)
logger.info("[MY-PLUGIN] Daemon started")
def stop():
"""Called by plugin_loader when the plugin unloads."""
global _thread
_stop_event.set()
if _thread and _thread.is_alive():
_thread.join(timeout=5)
_thread = None
logger.info("[MY-PLUGIN] Daemon stopped")
start(plugin_loader, settings)
| Param | Type | Description |
|---|---|---|
plugin_loader |
PluginLoader |
The plugin loader instance — used for emitting events and reading state |
settings |
dict |
Plugin settings (merged manifest defaults + user overrides) |
Called once when the plugin loads. If the scheduler isn't ready yet (early boot), the call is deferred automatically — you don't need to handle this.
stop()
Called when the plugin unloads (toggle off, hot-reload, shutdown). Clean up threads, close connections.
Emitting Events
When your daemon detects something interesting, emit an event:
import json
def _run():
while not _stop_event.is_set():
events = poll_my_service()
for event in events:
payload = json.dumps({
"account": "main",
"sender": event.sender,
"channel": event.channel,
"text": event.text,
})
_plugin_loader.emit_daemon_event("my_event", payload)
_stop_event.wait(60) # poll interval
emit_daemon_event(source_name, event_data)
| Param | Type | Description |
|---|---|---|
source_name |
str |
Must match an event_sources[].name from your manifest |
event_data |
str |
JSON string — the event payload passed to matching tasks |
The payload keys should include anything declared in your filter_fields so the scheduler can match filters. Include enough context for the LLM to understand what happened.
Sending images (vision)
A daemon event can carry images so a vision-capable model sees them (e.g. a
Discord message with an attached screenshot). Add an images key to the
payload — a list of {"data": <base64>, "media_type": ...} objects:
import base64
payload = json.dumps({
"account": "main",
"sender": event.sender,
"text": event.text,
"images": [
{
"data": base64.b64encode(image_bytes).decode(), # raw base64, NOT a data: URI
"media_type": "image/png", # png | jpeg | gif | webp
},
],
})
_plugin_loader.emit_daemon_event("my_event", payload)
Contract and behavior:
- Field name must be exactly
images, each entry exactly{"data", "media_type"}.datais plain base64 (nodata:image/...;base64,prefix). Anything else is ignored. - Validated at the boundary: non-image
media_type, undecodable base64, or images over 10 MB are dropped with a log; max 8 images per event. Malformed entries never reach the model or the chat — they're skipped, not stringified. - Vision gate: the image is sent to the model only if the task's provider supports vision. For OpenAI-compatible / Anthropic-compatible custom providers, enable the 👁 vision checkbox on the provider (Settings → LLM). Non-vision providers get a text description of the image instead (local, no network).
- Persistence: the image is stored once in the chat DB and shown in history via a marker — the base64 is not re-sent every turn, so it won't bloat the chat.
Reply Handlers
If your daemon supports auto-reply (sending the AI's response back to the source platform), register a reply handler in start():
plugin_loader.register_reply_handler("my-plugin", _reply_handler)
The handler is called when an event-triggered task completes:
def _reply_handler(task, event_data, response_text):
"""Route LLM response back to the source.
Args:
task: The task dict that was triggered
event_data: The original event payload (dict, already parsed)
response_text: The LLM's response text
"""
channel = event_data.get("channel")
send_to_my_service(channel, response_text)
The reply handler name in register_reply_handler must match your plugin name, and the source name must be registered by your plugin — the system uses this to look up the correct handler.
Account-Aware Daemons
If your daemon manages multiple accounts, use active_daemon_accounts() to only connect accounts that have active tasks:
active = plugin_loader.active_daemon_accounts("my_event")
# Returns: {"account1", "account2"} — only accounts with enabled daemon tasks
This avoids connecting to accounts nobody is listening on.
Lifecycle
- Plugin loads →
start(plugin_loader, settings)called - If scheduler not ready → start is deferred until scheduler registers
- Daemon thread runs, polling/listening for events
- Event detected →
emit_daemon_event()→ scheduler fires matching tasks - Task completes with auto-reply → reply handler routes response back
- Plugin unloads →
stop()called → clean up threads/connections
Daemons survive hot-reload — stop() is called before unload, then start() again after reload.
Real Examples
| Plugin | Event Source | What It Does |
|---|---|---|
plugins/telegram/ |
telegram_message |
Telethon client, listens for incoming messages via asyncio event loop |
plugins/discord/ |
discord_message |
py-cord bot, listens for channel messages (also voice, presence, proactive schedules) |
plugins/email/ |
email_message |
IMAP polling (configurable interval), checks for UNSEEN mail |
plugins/mcp_client/ |
— | MCP server connections (daemon without event sources) |
Reference for AI
DAEMON SYSTEM:
- Plugin declares
capabilities.daemonwithentry(module path) andevent_sources(what events it emits) - Module exports
start(plugin_loader, settings)andstop() - Daemon thread listens for external events, calls
plugin_loader.emit_daemon_event(source_name, json_payload) - Scheduler finds tasks matching source, fires them with event data as context
- Reply handlers route LLM responses back to source platform
active_daemon_accounts(source_name)returns set of accounts with active tasks- Filter fields: AND-matched against event payload
- Task fields: per-task config — select (static
optionsordynamicAPI URL), boolean (default), number (min/max), string default (placeholder,widget: "password"masks); all take key/label/required/help realtime: trueon an event source = gate-only:emit_daemon_eventfires NO tasks; the daemon reads gates viaactive_daemon_accounts(source)+get_enabled_daemon_task(source, account, payload)(most-specific filter wins, no match => decline the session)- Lifecycle: start on load, stop on unload, survives hot-reload
- Images: add
"images": [{"data": <base64>, "media_type": "image/png"}]to the payload — sent to vision-capable providers (gated on the provider's vision support), stored once in the chat DB via a marker (no per-turn replay bloat); max 8 images, 10 MB each, png/jpeg/gif/webp
Edit this page on GitHub · docs/plugin-author/daemons.md @ c2e3170 · synced
