Teleton Plugin Builder
Build plugins for Teleton, the Telegram AI agent on TON. Ask the user what plugin or tools they want to build, then follow this workflow.
- Agent runtime: github.com/TONresistor/teleton-agent — core agent, plugin loader, bridge, wallet
- Plugin directory: github.com/TONresistor/teleton-plugins — community plugins
- Plugin dev guide: CONTRIBUTING.md
Workflow
- Ask the user what they want (plugin name, what it does, which API or bot)
- Plan — present a structured plan (see below) and ask for validation
- Build — create all files once the user approves
- Install — copy to
~/.teleton/plugins/and restart
Step 1 — Understand the request
Determine:
- Plugin name — short, lowercase folder name (e.g.
pic,deezer,weather) - Plugin type:
- Inline bot — wraps a Telegram inline bot (@pic, @vid, @gif, @DeezerMusicBot…)
- Public API — calls an external REST API, no auth
- Auth API — external API with Telegram WebApp auth
- On-chain — signs and sends TON transactions from the agent wallet
- Local logic — pure JavaScript, no external calls
- Tools — list of tool names, what each does, parameters
- Does it need GramJS? — yes for inline bots and WebApp auth
- Does it need wallet signing? — yes for on-chain plugins
Step 2 — Present the plan
Show this to the user and wait for approval:
Plugin: [name]
Type: [Inline bot | Public API | Auth API | On-chain | Local logic]
Tools:
| Tool | Description | Params |
|-------------|--------------------------|-------------------------------------|
| `tool_name` | What it does | `query` (string, required), `index` (int, optional) |
Files:
- plugins/[name]/index.js
- plugins/[name]/manifest.json
- plugins/[name]/README.md
- registry.json (update)
Do NOT build until the user says go.
Step 3 — Build
Create all files in plugins/[name]/.
index.js
ESM only — always export const tools = [...], never module.exports.
GramJS import (only if plugin needs Telegram MTProto)
import { createRequire } from "node:module";
import { realpathSync } from "node:fs";
const _require = createRequire(realpathSync(process.argv[1]));
const { Api } = _require("telegram");
Tool definition
const myTool = {
name: "prefix_tool_name",
description: "The LLM reads this to decide when to call the tool. Be specific.",
parameters: {
type: "object",
properties: {
query: { type: "string", description: "Search query" },
index: { type: "integer", description: "Which result (0 = first)", minimum: 0, maximum: 49 },
},
required: ["query"],
},
execute: async (params, context) => {
try {
const index = params.index ?? 0;
// logic here
return { success: true, data: { result: "..." } };
} catch (err) {
return { success: false, error: String(err.message || err).slice(0, 500) };
}
},
};
export const tools = [myTool];
Inline bot pattern
See plugins/pic/index.js for a complete example.
execute: async (params, context) => {
try {
const client = context.bridge.getClient().getClient();
const bot = await client.getEntity("BOT_USERNAME");
const peer = await client.getInputEntity(context.chatId);
const results = await client.invoke(
new Api.messages.GetInlineBotResults({
bot, peer, query: params.query, offset: "",
})
);
if (!results.results || results.results.length === 0) {
return { success: false, error: `No results found for "${params.query}"` };
}
const index = params.index ?? 0;
if (index >= results.results.length) {
return { success: false, error: `Only ${results.results.length} results, index ${index} out of range` };
}
const chosen = results.results[index];
await client.invoke(
new Api.messages.SendInlineBotResult({
peer,
queryId: results.queryId,
id: chosen.id,
randomId: BigInt(Math.floor(Math.random() * 2 ** 62)),
})
);
return {
success: true,
data: {
query: params.query,
sent_index: index,
total_results: results.results.length,
title: chosen.title || null,
description: chosen.description || null,
type: chosen.type || null,
},
};
} catch (err) {
return { success: false, error: String(err.message || err).slice(0, 500) };
}
}
Public API pattern
See plugins/giftstat/index.js for a complete example.
const API_BASE = "https://api.example.com";
async function apiFetch(path, params = {}) {
const url = new URL(path, API_BASE);
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
url.searchParams.set(key, String(value));
}
}
const res = await fetch(url, { signal: AbortSignal.timeout(15000) });
if (!res.ok) {
throw new Error(`API error: ${res.status} ${await res.text().catch(() => "")}`);
}
return res.json();
}
WebApp auth pattern (Telegram-authenticated APIs)
See plugins/gaspump/index.js for a complete example.
let cachedAuth = null;
let cachedAuthTime = 0;
const AUTH_TTL = 30 * 60 * 1000;
async function getAuth(bridge, botUsername, webAppUrl) {
if (cachedAuth && Date.now() - cachedAuthTime < AUTH_TTL) return cachedAuth;
const client = bridge.getClient().getClient();
const bot = await client.getEntity(botUsername);
const result = await client.invoke(
new Api.messages.RequestWebView({ peer: bot, bot, platform: "android", url: webAppUrl })
);
const fragment = new URL(result.url).hash.slice(1);
const initData = new URLSearchParams(fragment).get("tgWebAppData");
if (!initData) throw new Error("Failed to extract tgWebAppData");
cachedAuth = initData;
cachedAuthTime = Date.now();
return cachedAuth;
}
// Auth fetch with retry on expiry
async function authFetch(bridge, path, opts = {}) {
const doFetch = async (auth) => {
const res = await fetch(new URL(path, API_BASE), {
...opts,
headers: { ...opts.headers, Authorization: auth },
signal: AbortSignal.timeout(15000),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`API error: ${res.status} ${text}`);
}
return res.json();
};
try {
return await doFetch(await getAuth(bridge, "botUsername", "https://webapp.url"));
} catch (err) {
if (/permission denied|unauthorized|403|401/i.test(err.message)) {
cachedAuth = null;
return await doFetch(await getAuth(bridge, "botUsername", "https://webapp.url"));
}
throw err;
}
}
On-chain / wallet signing pattern (TON transactions)
See plugins/stormtrade/index.js for a complete example.
import { createRequire } from "node:module";
import { readFileSync, realpathSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
const _require = createRequire(realpathSync(process.argv[1]));
const { Address, beginCell, SendMode } = _require("@ton/core");
const { WalletContractV5R1, TonClient, internal } = _require("@ton/ton");
const { mnemonicToPrivateKey } = _require("@ton/crypto");
const WALLET_FILE = join(homedir(), ".teleton", "wallet.json");
async function getWalletAndClient() {
const walletData = JSON.parse(readFileSync(WALLET_FILE, "utf-8"));
const keyPair = await mnemonicToPrivateKey(walletData.mnemonic);
const wallet = WalletContractV5R1.create({ workchain: 0, publicKey: keyPair.publicKey });
let endpoint;
try {
const { getHttpEndpoint } = _require("@orbs-network/ton-access");
endpoint = await getHttpEndpoint({ network: "mainnet" });
} catch {
endpoint = "https://toncenter.com/api/v2/jsonRPC";
}
const client = new TonClient({ endpoint });
const contract = client.open(wallet);
return { wallet, keyPair, client, contract };
}
// Usage in execute:
const { wallet, keyPair, client, contract } = await getWalletAndClient();
const seqno = await contract.getSeqno();
await contract.sendTransfer({
seqno,
secretKey: keyPair.secretKey,
sendMode: SendMode.PAY_GAS_SEPARATELY + SendMode.IGNORE_ERRORS,
messages: [
internal({ to: txParams.to, value: txParams.value, body: txParams.body, bounce: true }),
],
});
manifest.json
{
"id": "PLUGIN_ID",
"name": "Display Name",
"version": "1.0.0",
"description": "One-line description",
"author": { "name": "teleton", "url": "https://github.com/TONresistor" },
"license": "MIT",
"entry": "index.js",
"teleton": ">=1.0.0",
"tools": [
{ "name": "tool_name", "description": "Short description" }
],
"permissions": [],
"tags": ["tag1", "tag2"],
"repository": "https://github.com/TONresistor/teleton-plugins",
"funding": null
}
Set
"permissions": ["bridge"]only if the plugin usescontext.bridge. Default is[]. Theidfield must match the plugin folder name.
README.md
# Plugin Name
One-line description.
| Tool | Description |
|------|-------------|
| `tool_name` | What it does |
## Install
mkdir -p ~/.teleton/plugins
cp -r plugins/PLUGIN_ID ~/.teleton/plugins/
## Usage examples
- "Natural language prompt the user would say"
- "Another example prompt"
## Tool schema
### tool_name
| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `query` | string | Yes | — | Search query |
registry.json
Add to the plugins array:
{
"id": "PLUGIN_ID",
"name": "Display Name",
"description": "One-line description",
"author": "teleton",
"tags": ["tag1", "tag2"],
"path": "plugins/PLUGIN_ID"
}
Step 4 — Install and verify
cp -r plugins/PLUGIN_ID ~/.teleton/plugins/
Verify the plugin loads:
node -e "import('./plugins/PLUGIN_ID/index.js').then(m => console.log(m.tools.length, 'tools exported'))"
After restarting Teleton, check console output:
Plugin "PLUGIN_ID": N tools registered <- success
Commit if the user wants: git add plugins/PLUGIN_ID/ registry.json && git commit -m "PLUGIN_NAME: short description"
Advanced patterns
Factory function for similar endpoints
When multiple tools share the same fetch/paginate logic, use a factory:
function makePaginatedTool(name, description, endpoint, extraParams = {}) {
return {
name,
description,
parameters: {
type: "object",
properties: {
limit: { type: "integer", description: "Results per page", minimum: 1, maximum: 100 },
offset: { type: "integer", description: "Pagination offset", minimum: 0 },
...extraParams,
},
required: [],
},
execute: async (params) => {
try {
const data = await apiFetch(endpoint, { limit: params.limit ?? 20, offset: params.offset ?? 0 });
return { success: true, data };
} catch (err) {
return { success: false, error: String(err.message || err).slice(0, 500) };
}
},
};
}
export const tools = [
makePaginatedTool("prefix_list_items", "List all items", "/items"),
makePaginatedTool("prefix_list_users", "List all users", "/users"),
];
Enum parameters
Use enum in JSON Schema for fixed option sets:
properties: {
direction: { type: "string", enum: ["long", "short"], description: "Trade direction" },
order_type: { type: "string", enum: ["stopLoss", "takeProfit", "stopLimit"], description: "Order type" },
}
Helper functions
Extract repeated logic into reusable helpers:
function parseAmount(amount, vault) {
const v = (vault ?? "usdt").toLowerCase();
if (v === "usdt") return toStablecoin(Number(amount)); // 6 decimals
return numToNano(Number(amount)); // 9 decimals
}
Multi-step operations
Track each step and return context for the LLM:
execute: async (params, context) => {
const steps = [];
try {
const auth = await getAuth(context.bridge);
steps.push("authenticated");
const upload = await uploadImage(auth, params.image);
steps.push(`image uploaded: ${upload.url}`);
const token = await createToken(auth, { ...params, image_url: upload.url });
steps.push(`token created: ${token.address}`);
return { success: true, data: { ...token, steps } };
} catch (err) {
return { success: false, error: String(err.message || err).slice(0, 500), steps };
}
}
Parameter validation
Validate inputs explicitly before processing:
if (params.sides < 2 || params.sides > 100) {
return { success: false, error: "sides must be between 2 and 100" };
}
if (!Array.isArray(params.choices) || params.choices.length < 2) {
return { success: false, error: "choices must have at least 2 items" };
}
Rules
- ESM only —
export const tools, nevermodule.exports - JS only — the plugin loader reads
.jsfiles only - Tool names —
snake_case, prefixed with plugin name or short unique prefix (e.g.gas_,storm_,gift_), globally unique - Folder = ID — plugin folder name must match the
idfield in manifest.json - Permissions — set
"permissions": ["bridge"]in manifest.json if the plugin usescontext.bridge, otherwise[] - Defaults — use
??(nullish coalescing), never|| - Errors — always try/catch in execute, return
{ success: false, error }, slice errors to 500 chars - Timeouts —
AbortSignal.timeout(15000)on all externalfetch()calls - No npm deps — plugins cannot install packages. Use native
fetchand runtime packages (@ton/core,@ton/ton,@ton/crypto,telegram) - GramJS — always
createRequire(realpathSync(process.argv[1])), neverimport from "telegram" - Client chain —
context.bridge.getClient().getClient()for raw GramJS client
Context object
| Field | Type | Description |
|---|---|---|
bridge | TelegramBridge | Telegram access — messages, reactions, media, raw GramJS MTProto |
db | better-sqlite3 Database | SQLite instance at ~/.teleton/memory.db for persistence |
chatId | string | Current Telegram chat ID |
senderId | number | Telegram user ID of caller |
isGroup | boolean | true = group, false = DM |
config | Config | undefined | Agent YAML config (may be undefined) |
Bridge methods
// Send a message
await context.bridge.sendMessage({ chatId, text, replyToId?, inlineKeyboard? });
// Edit an existing message
await context.bridge.editMessage({ chatId, messageId, text, inlineKeyboard? });
// Send a reaction emoji
await context.bridge.sendReaction(chatId, messageId, emoji);
// Show typing indicator
await context.bridge.setTyping(chatId);
// Get recent messages (returns TelegramMessage[])
const msgs = await context.bridge.getMessages(chatId, limit);
// Get cached peer entity
const peer = context.bridge.getPeer(chatId);
// Get raw GramJS MTProto client (full Api.* namespace)
const gramjs = context.bridge.getClient().getClient();
// Get agent's own user ID / username
const myId = context.bridge.getOwnUserId();
const username = context.bridge.getUsername();
Tool fields
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Unique name across all plugins, snake_case with plugin prefix |
description | string | Yes | What the tool does — the LLM reads this to decide when to call it |
parameters | object | No | JSON Schema for params. Defaults to empty object if omitted |
execute | async function | Yes | (params, context) => Promise<{ success, data/error }> |
Return format
// Success — data is serialized to JSON and fed back to the LLM
return { success: true, data: { /* anything */ } };
// Error — error string shown to LLM, always slice
return { success: false, error: String(err.message || err).slice(0, 500) };
Runtime packages
Provided by the Teleton runtime and available via createRequire:
| Package | Use case |
|---|---|
telegram (GramJS) | MTProto client, Api.* namespace |
@ton/core | Cell building, Address, beginCell, SendMode |
@ton/ton | WalletContractV5R1, TonClient, internal |
@ton/crypto | mnemonicToPrivateKey |
@orbs-network/ton-access | Decentralized TON API endpoints |
better-sqlite3 | SQLite (accessed via context.db) |
Plugins cannot install their own npm packages. If an SDK is needed, it must be installed in the teleton runtime's node_modules/.
Agent wallet
On-chain plugins sign transactions from ~/.teleton/wallet.json (WalletContractV5R1, generated during teleton setup).
Plugin loading
The agent loads plugins from ~/.teleton/plugins/ at startup (source):
- Scans directory for
.jsfiles anddirectory/index.js - Dynamic
import()each entry - Validates
toolsarray withname,description,execute - Checks name collisions — first registered wins, collisions silently skipped
- Load order: core tools → built-in modules → external plugins
Example plugins
| Plugin | Type | Source |
|---|---|---|
example | Local logic | plugins/example/index.js |
pic | Inline bot | plugins/pic/index.js |
giftstat | Public API | plugins/giftstat/index.js |
gaspump | Auth API + On-chain | plugins/gaspump/index.js |
stormtrade | On-chain + SDK | plugins/stormtrade/index.js |