Create a Plugin
A comprehensive, step-by-step guide to building custom Teleton Agent plugins -- from a minimal hello-world to a full production-ready example.
1. Overview
Plugins extend Teleton Agent with custom tools that the LLM can invoke during conversations. There are two authoring approaches:
- With the SDK (TypeScript) -- full type safety, autocomplete, and compile-time checks via
@teleton-agent/sdk. - Without the SDK (Plain JavaScript) -- same runtime structure, zero build step required. No autocomplete, but fully functional.
Regardless of approach, a plugin is a single .js file (or a directory with an index.js) placed in ~/.teleton/plugins/. At minimum it exports a manifest object and a tools function.
2. Quick Start (Minimal Plugin)
The absolute minimum working plugin -- a manifest and one tool:
export const manifest = {
name: "hello-world",
version: "1.0.0",
description: "A simple greeting plugin"
};
export const tools = (sdk) => [{
name: "hello_greet",
description: "Greet a user by name",
parameters: {
type: "object",
properties: {
name: { type: "string", description: "Name to greet" }
},
required: ["name"]
},
execute: async (params) => ({
success: true,
data: { message: `Hello, ${params.name}!` }
})
}];Save the file as ~/.teleton/plugins/hello-world.js and restart Teleton. The agent will now have a hello_greet tool available.
3. With SDK (TypeScript)
Install the SDK for full type safety and editor autocomplete:
mkdir my-plugin && cd my-plugin
npm init -y
npm install @teleton-agent/sdk typescript
npx tsc --initCreate your plugin source with full type annotations:
import type {
PluginManifest,
PluginSDK,
SimpleToolDef,
PluginToolContext
} from "@teleton-agent/sdk";
// ---- Manifest ----
export const manifest: PluginManifest = {
name: "weather-plugin",
version: "1.0.0",
description: "Fetch current weather for any city",
author: "you",
dependencies: ["ton"],
sdkVersion: ">=1.0.0",
secrets: {
weather_api_key: {
required: true,
description: "API key for the weather service"
}
}
};
// ---- Tools ----
export const tools = (sdk: PluginSDK): SimpleToolDef[] => [
{
name: "weather_current",
description: "Get current weather for a city",
parameters: {
type: "object",
properties: {
city: { type: "string", description: "City name" }
},
required: ["city"]
},
async execute(params: { city: string }, context: PluginToolContext) {
const apiKey = sdk.secrets.require("weather_api_key");
const res = await fetch(
`https://api.weather.example.com/v1?city=${encodeURIComponent(params.city)}&key=${apiKey}`
);
const data = await res.json();
// Send result directly to the chat
await sdk.telegram.sendMessage(
context.chatId,
`Weather in ${params.city}: ${data.temp}°C, ${data.conditions}`
);
sdk.log.info(`Fetched weather for ${params.city}`);
return {
success: true,
data: {
city: params.city,
temperature: data.temp,
conditions: data.conditions
}
};
}
}
];
// ---- Lifecycle ----
export async function start(ctx: any) {
ctx.log.info("Weather plugin loaded");
}Build and install:
# Compile TypeScript to JavaScript
npx tsc
# Copy the compiled output to the plugins directory
cp dist/index.js ~/.teleton/plugins/weather-plugin.jsNote: If your plugin has npm dependencies, both package.json and package-lock.json must be present in the plugin directory. The runtime installs dependencies automatically on first load, but requires the lockfile to ensure deterministic installs.
4. Without SDK (Plain JavaScript)
The exact same structure works without TypeScript. You lose autocomplete but gain simplicity -- no build step, no dependencies:
export const manifest = {
name: "weather-plugin",
version: "1.0.0",
description: "Fetch current weather for any city",
secrets: {
weather_api_key: {
required: true,
description: "API key for the weather service"
}
}
};
export const tools = (sdk) => [{
name: "weather_current",
description: "Get current weather for a city",
parameters: {
type: "object",
properties: {
city: { type: "string", description: "City name" }
},
required: ["city"]
},
async execute(params, context) {
const apiKey = sdk.secrets.require("weather_api_key");
const res = await fetch(
`https://api.weather.example.com/v1?city=${encodeURIComponent(params.city)}&key=${apiKey}`
);
const data = await res.json();
await sdk.telegram.sendMessage(
context.chatId,
`Weather in ${params.city}: ${data.temp}°C, ${data.conditions}`
);
return { success: true, data };
}
}];
export async function start(ctx) {
ctx.log.info("Weather plugin loaded");
}5. Plugin Manifest (Detailed)
The manifest export describes your plugin to the runtime. All available fields:
| Field | Required | Description |
|---|---|---|
name | Yes | Unique identifier. Lowercase, alphanumeric + hyphens only, 1-64 characters. |
version | Yes | Semantic version string, e.g. "1.2.3". |
description | No | Human-readable summary, max 256 characters. |
author | No | Author name or handle. |
dependencies | No | Array of required Teleton modules, e.g. ["deals", "ton"]. Plugin will not load if a dependency is missing. |
defaultConfig | No | Default configuration object. Merged with values from config.yaml under plugins.{name}. |
sdkVersion | No | Semver range for SDK compatibility, e.g. ">=1.0.0". Plugin is skipped if the installed SDK does not match. |
secrets | No | Object declaring required or optional API keys. See Using Secrets. |
bot | No | Bot capabilities declaration: { inline?: boolean, callbacks?: boolean }. Required for sdk.bot to be non-null — without this field, sdk.bot will always be null. |
export const manifest = {
name: "my-analytics",
version: "2.1.0",
author: "alice",
description: "Track and report on-chain analytics",
dependencies: ["ton"],
sdkVersion: ">=1.0.0",
defaultConfig: {
refresh_interval: 60000,
max_results: 50
},
secrets: {
analytics_key: { required: true, description: "Analytics API key" },
webhook_url: { required: false, description: "Optional webhook for alerts" }
}
};6. Defining Tools
Each tool is an object with a name, description, JSON Schema parameters, and an execute function. The LLM reads the name and description to decide when to call a tool.
The tools export supports two forms. Use the function form when your tools need access to the sdk object (most cases), or the static array form when your tools have no SDK dependencies:
// Function form — sdk is injected at plugin load time (most common)
export const tools = (sdk) => [
{
name: "my_tool",
// ...
async execute(params, context) {
return sdk.ton.getBalance(params.address); // sdk is in scope
}
}
];
// Static array form — no sdk access needed
export const tools = [
{
name: "my_tool",
// ...
async execute(params, context) {
return { success: true, data: { result: params.input } };
}
}
];Parameter JSON Schema Patterns
parameters: {
type: "object",
properties: {
// String parameter
query: { type: "string", description: "Search query" },
// Number parameter
limit: { type: "number", description: "Max results", default: 10 },
// Enum parameter
network: {
type: "string",
enum: ["mainnet", "testnet"],
description: "TON network to use"
},
// Optional parameter (omit from required array)
verbose: { type: "boolean", description: "Include extra details" },
// Array parameter
tags: {
type: "array",
items: { type: "string" },
description: "Filter by tags"
}
},
required: ["query"] // Only query is required
}Tool Scope
Control where a tool can be invoked by setting the scope field:
| Scope | Description |
|---|---|
"always" (default) | Available in all chats |
"dm-only" | Only in direct messages with the bot |
"group-only" | Only in group chats |
"admin-only" | Only for admin users |
Tool Category
Categorize your tool so the agent understands its side effects:
"action"-- modifies state (sends a message, creates a deal, transfers TON)."data-bearing"-- returns data without side effects (fetch balance, list items).
{
name: "admin_reset_cache",
description: "Clear the plugin cache (admin only)",
scope: "admin-only",
category: "action",
parameters: { type: "object", properties: {} },
async execute(params, context) {
// ...
return { success: true, data: { cleared: true } };
}
}7. Using the Database
Plugins can persist data in a per-plugin SQLite database located at ~/.teleton/plugins/data/{name}.db. To get access to sdk.db, you must export a migrate() function:
export function migrate(db) {
db.exec(`CREATE TABLE IF NOT EXISTS alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id INTEGER NOT NULL,
token TEXT NOT NULL,
target_price REAL NOT NULL,
direction TEXT CHECK(direction IN ('above', 'below')) NOT NULL,
created_at INTEGER DEFAULT (unixepoch())
)`);
db.exec(`CREATE TABLE IF NOT EXISTS alert_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
alert_id INTEGER REFERENCES alerts(id),
triggered_at INTEGER DEFAULT (unixepoch()),
price_at_trigger REAL NOT NULL
)`);
}Key rules:
- Migrations must be idempotent. Always use
CREATE TABLE IF NOT EXISTS,CREATE INDEX IF NOT EXISTS, etc. - better-sqlite3 synchronous API. All operations are synchronous.
- Each plugin gets its own isolated database file. You cannot access another plugin's data.
- If you do not export
migrate(),sdk.dbwill benull.
async execute(params, context) {
if (!sdk.db) {
return { success: false, error: "Database not available" };
}
const stmt = sdk.db.prepare(
"INSERT INTO alerts (chat_id, token, target_price, direction) VALUES (?, ?, ?, ?)"
);
const result = stmt.run(context.chatId, params.token, params.price, params.direction);
return {
success: true,
data: { alertId: result.lastInsertRowid }
};
}8. Using Secrets
Declare the secrets your plugin needs in the manifest:
export const manifest = {
name: "my-plugin",
version: "1.0.0",
secrets: {
api_key: { required: true, description: "External API key" },
webhook_secret: { required: false, description: "Optional webhook signing secret" }
}
};Setting secrets. Users configure secrets via the Telegram command:
/plugin set my-plugin api_key sk_live_abc123...Accessing secrets in code:
// Throws if the secret is not set (for required secrets)
const apiKey = sdk.secrets.require("api_key");
// Returns undefined if not set (for optional secrets)
const webhook = sdk.secrets.get("webhook_secret");Resolution order: environment variable → /plugin set value → pluginConfig in config.yaml → undefined.
9. Using Storage (KV)
For simple key-value data that does not need a full database, use sdk.storage. No migrate() export is required:
// Set a value
sdk.storage.set("counter", 42);
// Set with TTL (auto-expires after 5 minutes)
sdk.storage.set("price_cache", data, { ttl: 300000 });
// Get a value (returns undefined if expired or not set)
const count = sdk.storage.get("counter"); // 42
// Delete a value
sdk.storage.delete("counter");
// Check existence
const exists = sdk.storage.has("price_cache"); // true or falseStorage is suitable for caches, counters, and small transient state. For structured or relational data, use the database instead.
10. Sending Messages
Use sdk.telegram to send messages, photos, and other media from within your tools or lifecycle hooks:
// Plain text message
await sdk.telegram.sendMessage(context.chatId, "Hello!");
// Message with Markdown formatting
await sdk.telegram.sendMessage(context.chatId, "*Bold* and _italic_ text", {
parseMode: "Markdown"
});
// Send a photo with caption
await sdk.telegram.sendPhoto(context.chatId, "/path/to/image.png", {
caption: "Look at this!"
});
// Send a document / file
await sdk.telegram.sendDocument(context.chatId, "/path/to/report.pdf", {
caption: "Your report is ready"
});11. Lifecycle Hooks
Export these functions to hook into the plugin lifecycle. All are optional:
// Called once when the plugin is loaded and the bridge is ready
export async function start(context) {
context.log.info("Plugin started, bridge connected");
// Start background jobs, timers, etc.
}
// Called when the plugin is being unloaded (shutdown or hot-reload)
export async function stop() {
// Cleanup: stop timers, close connections, flush buffers
}
// Called for every incoming message (not a tool -- no LLM context)
export async function onMessage(event) {
// React to messages directly, outside of tool invocation
if (event.text.includes("ping")) {
await event.reply("pong");
}
}
// Called when an inline keyboard button is pressed
export async function onCallbackQuery(event) {
const { action, params } = event;
await event.answer("Processing...");
}| Hook | When it fires | Common uses |
|---|---|---|
start(context) | Plugin loaded | Initialize timers, open connections, log startup |
stop() | Plugin unloading | Clear intervals, close sockets, flush caches |
onMessage(event) | Every incoming message | Keyword triggers, auto-moderation, logging |
onCallbackQuery(event) | Inline button pressed | Handle user choices from inline keyboards |
12. Inline Keyboards & Callbacks
Send interactive inline buttons and handle the user's selection:
await sdk.telegram.sendMessage(chatId, "Choose an option:", {
inlineKeyboard: [
[
{ text: "Option A", callback_data: "myplugin:choose:a" },
{ text: "Option B", callback_data: "myplugin:choose:b" }
],
[
{ text: "Cancel", callback_data: "myplugin:cancel" }
]
]
});The callback_data format is plugin:action:param1:param2. Handle it in onCallbackQuery:
export async function onCallbackQuery(event) {
if (event.action === "myplugin") {
const subAction = event.params[0]; // "choose" or "cancel"
if (subAction === "choose") {
const choice = event.params[1]; // "a" or "b"
await event.answer(`You chose ${choice}!`);
await sdk.telegram.sendMessage(
event.chatId,
`Selection confirmed: ${choice.toUpperCase()}`
);
} else if (subAction === "cancel") {
await event.answer("Cancelled.");
}
}
}13. Configuration
Plugin-specific settings live in your config.yaml under the plugins key. The key name must match your plugin's manifest.name (with hyphens replaced by underscores):
plugins:
my_plugin:
api_endpoint: "https://api.example.com"
max_retries: 3
cache_ttl: 60000Access configuration values via sdk.pluginConfig:
const endpoint = sdk.pluginConfig.api_endpoint;
// "https://api.example.com"
const retries = sdk.pluginConfig.max_retries || 1;
// 3If your manifest declares defaultConfig, those values are merged with the user's config.yaml values. User values take precedence.
14. Hot Reload (Development)
During development, enable hot reload so file changes are picked up automatically without restarting Teleton:
dev:
hot_reload: trueWhen enabled, Teleton watches ~/.teleton/plugins/ for file changes. Modified plugins are unloaded (triggering stop()), re-evaluated, and their tools replaced in the agent's tool set -- all without a full restart.
This is ideal for rapid iteration: edit your .js file, save, and the changes take effect within seconds.
15. Complete Example: Price Alert Plugin
A full, production-style plugin that monitors TON token prices and sends alerts. This demonstrates the manifest, database, secrets, storage, messaging, and logging features working together:
// ============================================================
// Price Alert Plugin -- monitors token prices and notifies users
// ============================================================
export const manifest = {
name: "price-alerts",
version: "1.0.0",
description: "Set price alerts for TON tokens and get notified",
author: "teleton-community",
dependencies: ["ton"],
secrets: {
coingecko_key: {
required: false,
description: "CoinGecko API key (optional, increases rate limit)"
}
},
defaultConfig: {
check_interval: 60000, // check every 60s
max_alerts_per_user: 20
}
};
// ---- Database migration ----
export function migrate(db) {
db.exec(`CREATE TABLE IF NOT EXISTS price_alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id INTEGER NOT NULL,
token TEXT NOT NULL,
target_price REAL NOT NULL,
direction TEXT CHECK(direction IN ('above', 'below')) NOT NULL,
active INTEGER DEFAULT 1,
created_at INTEGER DEFAULT (unixepoch())
)`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_alerts_active
ON price_alerts(active, token)`);
}
// ---- Tools ----
export const tools = (sdk) => [
{
name: "price_create_alert",
description: "Create a price alert for a TON token",
category: "action",
parameters: {
type: "object",
properties: {
token: { type: "string", description: "Token symbol (e.g. TON, USDT)" },
price: { type: "number", description: "Target price in USD" },
direction: {
type: "string",
enum: ["above", "below"],
description: "Trigger when price goes above or below target"
}
},
required: ["token", "price", "direction"]
},
async execute(params, context) {
if (!sdk.db) {
return { success: false, error: "Database not initialized" };
}
// Check user limit
const count = sdk.db
.prepare("SELECT COUNT(*) as cnt FROM price_alerts WHERE chat_id = ? AND active = 1")
.get(context.chatId);
const maxAlerts = sdk.pluginConfig.max_alerts_per_user || 20;
if (count.cnt >= maxAlerts) {
return {
success: false,
error: `Alert limit reached (${maxAlerts}). Delete some alerts first.`
};
}
const result = sdk.db.prepare(
"INSERT INTO price_alerts (chat_id, token, target_price, direction) VALUES (?, ?, ?, ?)"
).run(context.chatId, params.token.toUpperCase(), params.price, params.direction);
sdk.log.info(`Alert #${result.lastInsertRowid} created for ${params.token} ${params.direction} $${params.price}`);
return {
success: true,
data: {
alertId: result.lastInsertRowid,
token: params.token.toUpperCase(),
targetPrice: params.price,
direction: params.direction
}
};
}
},
{
name: "price_list_alerts",
description: "List all active price alerts for the current chat",
category: "data-bearing",
parameters: { type: "object", properties: {} },
async execute(params, context) {
if (!sdk.db) {
return { success: false, error: "Database not initialized" };
}
const alerts = sdk.db
.prepare("SELECT id, token, target_price, direction, created_at FROM price_alerts WHERE chat_id = ? AND active = 1 ORDER BY created_at DESC")
.all(context.chatId);
return { success: true, data: { alerts, count: alerts.length } };
}
},
{
name: "price_delete_alert",
description: "Delete (deactivate) a price alert by its ID",
category: "action",
parameters: {
type: "object",
properties: {
alert_id: { type: "number", description: "Alert ID to delete" }
},
required: ["alert_id"]
},
async execute(params, context) {
if (!sdk.db) {
return { success: false, error: "Database not initialized" };
}
const result = sdk.db.prepare(
"UPDATE price_alerts SET active = 0 WHERE id = ? AND chat_id = ?"
).run(params.alert_id, context.chatId);
if (result.changes === 0) {
return { success: false, error: "Alert not found or already deleted" };
}
sdk.log.info(`Alert #${params.alert_id} deleted`);
return { success: true, data: { deletedId: params.alert_id } };
}
}
];
// ---- Lifecycle: background price checker ----
let checkInterval = null;
export async function start(context) {
const sdk = context.sdk;
const interval = sdk.pluginConfig.check_interval || 60000;
context.log.info(`Price alerts plugin started (check every ${interval}ms)`);
checkInterval = setInterval(async () => {
try {
if (!sdk.db) return;
const activeAlerts = sdk.db
.prepare("SELECT DISTINCT token FROM price_alerts WHERE active = 1")
.all();
for (const { token } of activeAlerts) {
// Check cache first
const cacheKey = `price_${token}`;
let price = sdk.storage.get(cacheKey);
if (price === undefined) {
// Fetch fresh price
price = await sdk.ton.getPrice(token);
sdk.storage.set(cacheKey, price, { ttl: 30000 }); // cache 30s
}
// Check all alerts for this token
const triggered = sdk.db.prepare(
`SELECT * FROM price_alerts WHERE token = ? AND active = 1
AND ((direction = 'above' AND ? >= target_price)
OR (direction = 'below' AND ? <= target_price))`
).all(token, price, price);
for (const alert of triggered) {
await sdk.telegram.sendMessage(
alert.chat_id,
`Price Alert: ${alert.token} is now $${price} (${alert.direction} $${alert.target_price})`
);
// Deactivate triggered alert
sdk.db.prepare("UPDATE price_alerts SET active = 0 WHERE id = ?").run(alert.id);
sdk.log.debug(`Alert #${alert.id} triggered at $${price}`);
}
}
} catch (err) {
sdk.log.error(`Price check failed: ${err.message}`);
}
}, interval);
}
export async function stop() {
if (checkInterval) {
clearInterval(checkInterval);
checkInterval = null;
}
}16. Best Practices
- Idempotent migrations. Always use
CREATE TABLE IF NOT EXISTSandCREATE INDEX IF NOT EXISTS. Migrations run on every startup. - Handle
sdk.dbbeingnull. If you forget to exportmigrate(), database access will be null. Check before using. - Never hardcode API keys. Use
sdk.secretsto declare and access them. This keeps credentials out of source code. - Return structured errors. Always return
{ success: false, error: "descriptive message" }on failure so the LLM can relay useful information to the user. - Prefix tool names. Use your plugin name as a prefix (e.g.
price_create_alert) to avoid name collisions with other plugins. - Validate parameters. Do not trust input blindly. Check types, ranges, and lengths before processing.
- Log important operations. Use
sdk.log.info()for key actions andsdk.log.debug()for development detail. Avoid logging secrets. - Clean up in
stop(). Clear intervals, close connections, and flush any pending data when the plugin unloads. - Use storage TTL for caches. Set
{ ttl: ms }when caching API responses to prevent stale data.
17. Debugging
When something is not working as expected, use these techniques to diagnose issues:
- Enable verbose mode by sending
/verbosein Telegram. This surfaces detailed tool call logs and plugin output. - Look for the
[plugin:name]prefix in the console/log output. Every plugin log line is tagged with its plugin name for easy filtering. - Use
sdk.log.debug()for development-time information. These messages only appear in verbose mode. - Enable
hot_reloadinconfig.yamlfor rapid iteration. Edit, save, test -- no restart required.
async execute(params, context) {
sdk.log.debug(`Received params: ${JSON.stringify(params)}`);
sdk.log.debug(`Chat ID: ${context.chatId}, User: ${context.userId}`);
try {
const result = await someOperation(params);
sdk.log.info(`Operation succeeded: ${result.id}`);
return { success: true, data: result };
} catch (err) {
sdk.log.error(`Operation failed: ${err.message}`);
return { success: false, error: err.message };
}
}