ESC
Start typing to search...

Plugin Lifecycle

Every plugin implements the PluginModule interface. The agent calls each hook at a specific stage during startup and shutdown.

Lifecycle Overview

Hooks are called in this order during startup:

HookRequiredWhenPurpose
migrate(db)NoAlways (even if disabled)Create or update database tables
toolsYesAlwaysRegister tools with the agent (static array or function receiving PluginSDK)
start(context)NoAfter Telegram connectsStart background jobs, pollers, timers
stop()NoOn shutdown (SIGINT/SIGTERM)Clean up resources, stop timers
onMessage(event)NoOn every incoming Telegram messageReact to messages without tool dispatch
onCallbackQuery(event)NoOn inline keyboard button pressHandle callback query events from bot buttons

migrate is called even when the plugin has no explicit tools, so database schemas stay up to date. Return an empty array from tools to effectively disable a plugin without uninstalling it.

Plugin Exports (External Plugins)

External plugins use a flat-export format. Only tools is required. The tools export can be either a static array or a function that receives the PluginSDK:

my-plugin/index.js
import type { PluginSDK, PluginMessageEvent, PluginCallbackEvent } from "@teleton-agent/sdk";

// Option A: static array
export const tools = [
  {
    name: "my_tool",
    description: "Does something",
    parameters: { type: "object", properties: { text: { type: "string" } } },
    execute: async (params, ctx) => ({ success: true, data: params.text }),
  },
];

// Option B: function receiving PluginSDK
export const tools = (sdk: PluginSDK) => [
  {
    name: "my_tool",
    description: "Does something using the SDK",
    parameters: { type: "object", properties: { text: { type: "string" } } },
    execute: async (params) => {
      await sdk.telegram.sendMessage({ chatId: "me", text: params.text });
      return { success: true };
    },
  },
];

// Optional: DB migrations (plugin gets an isolated SQLite DB)
export function migrate(db) {
  db.exec(`CREATE TABLE IF NOT EXISTS my_logs (id INTEGER PRIMARY KEY, msg TEXT)`);
}

// Optional: background jobs after Telegram connects
export async function start(ctx) { /* ... */ }

// Optional: cleanup on shutdown
export async function stop() { /* ... */ }

// Optional: react to every incoming Telegram message
export async function onMessage(event: PluginMessageEvent) { /* ... */ }

// Optional: handle bot button callbacks
export async function onCallbackQuery(event: PluginCallbackEvent) { /* ... */ }

migrate(db)

Receives a better-sqlite3 Database instance. Create your tables and indexes here. This hook must be idempotent -- always use CREATE TABLE IF NOT EXISTS.

Example
migrate(db) {
  db.exec(`
    CREATE TABLE IF NOT EXISTS my_plugin_data (
      id    INTEGER PRIMARY KEY AUTOINCREMENT,
      key   TEXT NOT NULL UNIQUE,
      value TEXT,
      created_at INTEGER DEFAULT (unixepoch())
    )
  `);
}

tools (required)

The only required export. Defines the tools the plugin registers with the agent. Can be a static array or a function that receives the PluginSDK instance. Return an empty array to disable the plugin without uninstalling it.

Static array example
export const tools = [
  {
    name: "deal_propose",
    description: "Propose a P2P deal",
    parameters: { /* TypeBox or JSON schema */ },
    scope: "dm-only",
    execute: async (params, ctx) => { /* ... */ },
  },
];
Function receiving PluginSDK example
export const tools = (sdk) => [
  {
    name: "deal_status",
    description: "Check deal status",
    parameters: { /* ... */ },
    // scope defaults to "always"
    execute: async (params) => {
      const balance = await sdk.ton.getBalance();
      return { success: true, data: { balance } };
    },
  },
];
ScopeBehavior
"always"Available in both DMs and groups (default)
"dm-only"Excluded from group chats (financial, private tools)
"group-only"Excluded from DMs (moderation tools)
"admin-only"Restricted to admin users only

start(context)

Called after the Telegram bridge is connected and all tools are registered. Use this to start background jobs like pollers, timers, or bot instances. The PluginContext provides access to shared resources:

PluginContext
interface PluginContext {
  bridge: TelegramBridge;         // Send messages, react, etc.
  db: Database.Database;          // SQLite database instance
  config: Config;                 // Full runtime config
  pluginConfig?: Record<string, unknown>;  // External plugins only
  log?: (...args: unknown[]) => void;      // External plugins only
}
Example -- Deals module start
async start(context) {
  if (!context.config.deals?.enabled) return;

  const { config, bridge } = context;

  // Start a Telegram bot for deal confirmations
  dealBot = new DealBot({ token: config.telegram.bot_token }, dealsDb);
  await dealBot.start();

  // Start a background poller for payment verification
  verificationPoller = new VerificationPoller(dealsDb, bridge, dealBot);
  verificationPoller.start();

  // Expire stale deals on an interval
  expiryInterval = setInterval(() => {
    dealsDb.prepare(
      `UPDATE deals SET status = 'expired'
       WHERE status IN ('proposed','accepted') AND expires_at < unixepoch()`
    ).run();
  }, 60_000);
}

If start() throws, the error is caught and logged. No rollback occurs — other modules continue to start normally.

stop()

Called during graceful shutdown (SIGINT or SIGTERM). Stop all background jobs and release resources. Each module's stop() is called individually -- a failure in one module does not prevent others from stopping.

Example
async stop() {
  if (verificationPoller) {
    verificationPoller.stop();
    verificationPoller = null;
  }
  if (dealBot) {
    await dealBot.stop();
    dealBot = null;
  }
  if (expiryInterval) {
    clearInterval(expiryInterval);
    expiryInterval = null;
  }
  closeDealsDb();
}

onMessage(event)

Called for every incoming Telegram message, before the agent processes it. Use this to react to messages independently of the tool dispatch loop — for example, to log messages, filter spam, or send proactive replies.

Example
import type { PluginMessageEvent } from "@teleton-agent/sdk";

export async function onMessage(event: PluginMessageEvent) {
  const { chatId, text, senderId } = event;
  if (text?.toLowerCase().includes("hello")) {
    // React to greeting without going through the agent loop
  }
}

The PluginMessageEvent type is imported from @teleton-agent/sdk. It includes chatId, text, senderId, messageId, and the raw GramJS message object.

onCallbackQuery(event)

Called when a user presses an inline keyboard button created by a bot in the plugin's namespace. Use this to handle interactive button flows — confirmation dialogs, paged results, etc.

Example
import type { PluginCallbackEvent } from "@teleton-agent/sdk";

export async function onCallbackQuery(event: PluginCallbackEvent) {
  const { data, chatId, userId } = event;
  if (data === "confirm_deal") {
    // handle confirmation button press
  }
}

The PluginCallbackEvent type is imported from @teleton-agent/sdk. It includes data (the button payload string), chatId, userId, messageId, and the raw GramJS update object.

Startup Order

Understanding the full startup sequence helps when debugging plugin issues:

  1. Config loaded, built-in modules loaded (migrate + tools)
  2. External plugins loaded from ~/.teleton/plugins/ (migrate + tools)
  3. MCP servers connected and their tools registered
  4. Tool RAG index built (if enabled)
  5. Telegram bridge connects
  6. start(context) called on each module in order
  7. Message handler begins processing incoming messages

On shutdown, stop() is called on each module in sequence. The agent enforces a timeout -- if shutdown takes too long, the process is forcefully terminated.

Full Example

A minimal but complete plugin using all lifecycle hooks:

my-plugin/module.ts
import type { PluginModule, PluginContext } from "./types.js";
import { Type } from "@sinclair/typebox";

let pollInterval: ReturnType<typeof setInterval> | null = null;

const myPlugin: PluginModule = {
  name: "my-plugin",
  version: "1.0.0",

  migrate(db) {
    db.exec(`
      CREATE TABLE IF NOT EXISTS my_plugin_logs (
        id   INTEGER PRIMARY KEY AUTOINCREMENT,
        msg  TEXT NOT NULL,
        ts   INTEGER DEFAULT (unixepoch())
      )
    `);
  },

  tools(config) {
    return [
      {
        tool: {
          name: "my_plugin_hello",
          description: "Say hello from my plugin",
          parameters: Type.Object({
            name: Type.String({ description: "Name to greet" }),
          }),
        },
        executor: async (params, context) => {
          context.db.prepare(
            "INSERT INTO my_plugin_logs (msg) VALUES (?)"
          ).run(`Greeted ${params.name}`);

          return {
            success: true,
            data: { message: `Hello, ${params.name}!` },
          };
        },
      },
    ];
  },

  async start(context: PluginContext) {
    // Start a background job
    pollInterval = setInterval(() => {
      console.log("my-plugin background tick");
    }, 30_000);
  },

  async stop() {
    if (pollInterval) {
      clearInterval(pollInterval);
      pollInterval = null;
    }
  },
};

export default myPlugin;