Skip to content

Plugin Development

Build a plugin in one Python file. Drop it in plugins/ — auto-loaded.


Minimal Plugin

# my_plugin.py
from simplecontext.plugins.base import BasePlugin

class MyPlugin(BasePlugin):
    name        = "my_plugin"
    version     = "1.0.0"
    description = "What this plugin does."

    def on_before_llm(self, user_id, agent_id, messages):
        # Inject something into the system message
        if messages and messages[0]["role"] == "system":
            messages[0]["content"] += "\n\nExtra context here."
        return messages

Drop it in plugins/ and restart. Done.


Full Plugin Reference

from simplecontext.plugins.base import BasePlugin, AppCommandContext

class MyPlugin(BasePlugin):
    # ── Class attributes ──────────────────────────────────
    name        = "my_plugin"           # unique, snake_case
    version     = "1.0.0"
    description = "Short description."
    depends_on  = []                    # ["other_plugin"] if needed

    # ── App Commands (optional) ───────────────────────────
    # Host apps (Telegram bot, Discord, CLI) read this and
    # auto-register the commands. No changes to host code needed.
    app_commands = {
        "mycommand": {
            "description": "What this command does",
            "usage":       "/mycommand <arg>",
            "handler":     "handle_mycommand",   # method name below
            "args_hint":   "<query>",
            "hidden":      False,
        }
    }

    # ── Lifecycle ─────────────────────────────────────────

    def setup(self):
        """Called on init. self.state and self.config available."""
        self.option = self.config.get("option", "default")
        self.count  = self.state.get("count", 0)

    def teardown(self):
        """Called on shutdown. Clean up resources."""
        self.state.set("count", self.count)

    # ── App Command Handler ───────────────────────────────

    async def handle_mycommand(self, ctx: AppCommandContext) -> str:
        """
        Called by host app when user runs /mycommand.
        ctx.user_id   → str
        ctx.args      → list[str]
        ctx.args_str  → str (joined args)
        ctx.platform  → "telegram" | "discord" | "cli" | etc.
        ctx.raw       → raw platform object (e.g. Telegram Update)
        ctx.sc        → SimpleContext instance
        """
        return f"Hello from {ctx.platform}! Query: {ctx.args_str}"

    # ── Memory Hooks ─────────────────────────────────────

    def on_message_saved(self, user_id, role, content, tags, metadata):
        """Fires after each message is stored."""
        self.count += 1
        self.state.increment("total_messages")

    def on_messages_cleared(self, user_id):
        """Fires when user's memory is cleared."""

    def on_context_build(self, user_id, messages: list) -> list:
        """
        Fires when building context for LLM.
        Modify, filter, or enrich messages.
        MUST return list.
        """
        return messages

    # ── LLM Hooks ─────────────────────────────────────────

    def on_before_llm(self, user_id, agent_id, messages: list) -> list:
        """
        Fires just before sending to LLM.
        Last chance to modify messages.
        MUST return list.
        """
        return messages

    def on_after_llm(self, user_id, agent_id, response: str) -> str:
        """
        Fires after LLM responds.
        Modify, filter, or append to response.
        MUST return str.
        """
        return response

    # ── Skill Hooks ───────────────────────────────────────

    def on_skill_saved(self, agent_id, name, content): ...
    def on_skill_deleted(self, agent_id, name): ...

    def on_prompt_build(self, agent_id, prompt: str) -> str:
        """Fires after system prompt is built. MUST return str."""
        return prompt

    # ── Agent Hooks ───────────────────────────────────────

    def on_agent_routed(self, user_id, agent_id, message): ...
    def on_agent_chain(self, user_id, from_agent, to_agent, reason): ...

    # ── Export/Import Hooks ───────────────────────────────

    def on_export(self, data: dict) -> dict:
        """Fires on data export. MUST return dict."""
        return data

    def on_import(self, data: dict) -> dict:
        """Fires on data import. MUST return dict."""
        return data

Plugin State

Plugins have access to self.state — a persistent key-value store backed by the same DB as SimpleContext:

# Set
self.state.set("key", value)

# Get (with default)
value = self.state.get("key", default)

# Increment integer counter
total = self.state.increment("counter")      # → int

# Update multiple keys
self.state.update({"key1": val1, "key2": val2})

# Read all state for this plugin
all_data = self.state.all()                  # → dict

# Clear all state
self.state.clear()

State is namespaced per plugin — my_plugin.counter won't collide with other_plugin.counter.


Plugin Config

Users configure plugins in config.yaml:

plugins:
  my_plugin:
    enabled: true
    option: value
    threshold: 0.75

Read in your plugin:

def setup(self):
    self.option    = self.config.get("option", "default")
    self.threshold = float(self.config.get("threshold", 0.5))

App Commands

App commands let your plugin expose Telegram/Discord/CLI commands without touching the host app code.

app_commands = {
    "stats": {
        "description": "Show your usage statistics",
        "usage":       "/stats",
        "handler":     "handle_stats",
    }
}

async def handle_stats(self, ctx: AppCommandContext) -> str:
    total = self.state.get(f"messages:{ctx.user_id}", 0)
    return f"📊 You've sent {total} messages."

The handler receives an AppCommandContext:

@dataclass
class AppCommandContext:
    command:  str        # "stats"
    user_id:  str        # "123456"
    args:     list[str]  # ["arg1", "arg2"]
    args_str: str        # "arg1 arg2"
    platform: str        # "telegram" | "discord" | "cli" | "web"
    raw:      Any        # platform-specific object (e.g. Telegram Update)
    sc:       Any        # SimpleContext instance
    extra:    dict       # additional data from host

Plugin Dependencies

If your plugin requires another plugin to be loaded first:

class MyPlugin(BasePlugin):
    name       = "my_plugin"
    depends_on = ["logger_plugin"]   # logger_plugin loads first

The loader resolves dependencies automatically via topological sort. If a dependency is missing, it raises a clear error.


Examples

Timestamp injector

from datetime import datetime, timezone
from simplecontext.plugins.base import BasePlugin

class TimestampPlugin(BasePlugin):
    name        = "timestamp_plugin"
    version     = "1.0.0"
    description = "Inject current time into system message."
    depends_on  = []

    def setup(self):
        self.fmt = self.config.get("format", "%Y-%m-%d %H:%M UTC")

    def on_before_llm(self, user_id, agent_id, messages):
        now = datetime.now(timezone.utc).strftime(self.fmt)
        if messages and messages[0]["role"] == "system":
            messages[0]["content"] += f"\n\nCurrent time: {now}"
        else:
            messages.insert(0, {"role": "system", "content": f"Current time: {now}"})
        self.state.increment("injections")
        return messages

Usage logger

import json
from datetime import datetime, timezone
from simplecontext.plugins.base import BasePlugin

class LoggerPlugin(BasePlugin):
    name        = "logger_plugin"
    version     = "2.0.0"
    description = "Log messages and track usage stats."

    def setup(self):
        self.log_path = self.config.get("log_path", "./sc.log")

    def on_message_saved(self, user_id, role, content, tags, metadata):
        total = self.state.increment("total_messages")
        entry = {
            "ts":      datetime.now(timezone.utc).isoformat(),
            "user_id": user_id,
            "role":    role,
            "preview": content[:80],
            "total":   total,
        }
        with open(self.log_path, "a") as f:
            f.write(json.dumps(entry) + "\n")

    def on_after_llm(self, user_id, agent_id, response):
        self.state.increment("total_llm_calls")
        return response

Configuration Reference

plugins:
  enabled: true
  folder: ./plugins

  my_plugin:
    enabled: true
    option: value

Set enabled: false to disable a plugin without removing its file.