ESC
Start typing to search...

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:

~/.teleton/plugins/hello-world.js
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:

Terminal
mkdir my-plugin && cd my-plugin
npm init -y
npm install @teleton-agent/sdk typescript
npx tsc --init

Create your plugin source with full type annotations:

src/index.ts
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:

Terminal
# Compile TypeScript to JavaScript
npx tsc

# Copy the compiled output to the plugins directory
cp dist/index.js ~/.teleton/plugins/weather-plugin.js

Note: 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:

~/.teleton/plugins/weather-plugin.js
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:

FieldRequiredDescription
nameYesUnique identifier. Lowercase, alphanumeric + hyphens only, 1-64 characters.
versionYesSemantic version string, e.g. "1.2.3".
descriptionNoHuman-readable summary, max 256 characters.
authorNoAuthor name or handle.
dependenciesNoArray of required Teleton modules, e.g. ["deals", "ton"]. Plugin will not load if a dependency is missing.
defaultConfigNoDefault configuration object. Merged with values from config.yaml under plugins.{name}.
sdkVersionNoSemver range for SDK compatibility, e.g. ">=1.0.0". Plugin is skipped if the installed SDK does not match.
secretsNoObject declaring required or optional API keys. See Using Secrets.
botNoBot capabilities declaration: { inline?: boolean, callbacks?: boolean }. Required for sdk.bot to be non-null — without this field, sdk.bot will always be null.
Full manifest example
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:

Two valid tools export forms
// 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

Common parameter 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:

ScopeDescription
"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).
Scope and category example
{
  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:

Database migration
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.db will be null.
Using sdk.db in a tool
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:

Manifest secrets declaration
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:

Telegram
/plugin set my-plugin api_key sk_live_abc123...

Accessing secrets in code:

Reading secrets
// 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:

Key-value storage
// 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 false

Storage 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:

Sending messages and media
// 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:

Lifecycle hooks
// 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...");
}
HookWhen it firesCommon uses
start(context)Plugin loadedInitialize timers, open connections, log startup
stop()Plugin unloadingClear intervals, close sockets, flush caches
onMessage(event)Every incoming messageKeyword triggers, auto-moderation, logging
onCallbackQuery(event)Inline button pressedHandle user choices from inline keyboards

12. Inline Keyboards & Callbacks

Send interactive inline buttons and handle the user's selection:

Sending inline keyboards
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:

Handling callback queries
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):

config.yaml
plugins:
  my_plugin:
    api_endpoint: "https://api.example.com"
    max_retries: 3
    cache_ttl: 60000

Access configuration values via sdk.pluginConfig:

Reading config
const endpoint = sdk.pluginConfig.api_endpoint;
// "https://api.example.com"

const retries = sdk.pluginConfig.max_retries || 1;
// 3

If 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:

config.yaml
dev:
  hot_reload: true

When 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:

~/.teleton/plugins/price-alerts.js
// ============================================================
// 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 EXISTS and CREATE INDEX IF NOT EXISTS. Migrations run on every startup.
  • Handle sdk.db being null. If you forget to export migrate(), database access will be null. Check before using.
  • Never hardcode API keys. Use sdk.secrets to 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 and sdk.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 /verbose in 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_reload in config.yaml for rapid iteration. Edit, save, test -- no restart required.
Debugging example
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 };
  }
}