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:
| Hook | Required | When | Purpose |
|---|---|---|---|
migrate(db) | No | Always (even if disabled) | Create or update database tables |
tools | Yes | Always | Register tools with the agent (static array or function receiving PluginSDK) |
start(context) | No | After Telegram connects | Start background jobs, pollers, timers |
stop() | No | On shutdown (SIGINT/SIGTERM) | Clean up resources, stop timers |
onMessage(event) | No | On every incoming Telegram message | React to messages without tool dispatch |
onCallbackQuery(event) | No | On inline keyboard button press | Handle 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:
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.
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.
export const tools = [
{
name: "deal_propose",
description: "Propose a P2P deal",
parameters: { /* TypeBox or JSON schema */ },
scope: "dm-only",
execute: async (params, ctx) => { /* ... */ },
},
];
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 } };
},
},
];| Scope | Behavior |
|---|---|
"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:
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
}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.
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.
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.
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:
- Config loaded, built-in modules loaded (
migrate+tools) - External plugins loaded from
~/.teleton/plugins/(migrate+tools) - MCP servers connected and their tools registered
- Tool RAG index built (if enabled)
- Telegram bridge connects
start(context)called on each module in order- 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:
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;