Tutorial: Build an Inline Bot
Create an inline bot plugin with styled colored buttons, callback handlers, and inline query responses.
Goal
By the end of this tutorial, you will have a fully functional inline bot plugin that:
- Responds to inline queries with searchable results
- Displays styled colored buttons (green, red, blue) via GramJS Layer 222
- Handles callback button presses with glob patterns
- Tracks chosen inline results for analytics
We will build a TON Price Ticker — an inline bot that lets users search for token prices and share them in any chat with interactive buttons.
Prerequisites
- Teleton Agent running with a Telegram bot connected
- Inline mode enabled for your bot via @BotFather (
/setinline) - Inline feedback enabled via @BotFather (
/setinlinefeedback) if you wantonChosenResult - TON wallet configured (for price data)
Step 1: Manifest with BotManifest
The manifest must declare bot capabilities. Without this, sdk.bot will be null at runtime.
import type {
PluginSDK,
SimpleToolDef,
PluginManifest,
InlineResult,
InlineQueryContext,
CallbackContext,
ChosenResultContext,
} from "@teleton-agent/sdk";
export const manifest: PluginManifest = {
name: "ton-ticker",
version: "1.0.0",
description: "Inline TON price ticker — search tokens and share prices in any chat",
bot: {
inline: true, // Enable inline query handling
callbacks: true, // Enable callback query handling
rateLimits: {
inlinePerMinute: 60, // Higher limit for a price bot
callbackPerMinute: 120,
},
},
};Bot Properties: isAvailable and username
Before registering handlers, always check sdk.bot.isAvailable to confirm the bot client is connected. Use sdk.bot.username to construct @bot links for inline entry points.
export const start = async (sdk: PluginSDK) => {
// Always guard against null first (no BotManifest declared)
if (!sdk.bot) {
sdk.log.warn("Bot SDK not available — manifest missing bot declaration");
return;
}
// Then check if the bot client is actually connected
if (!sdk.bot.isAvailable) {
sdk.log.warn("Bot client is not yet connected — handlers will not fire");
return;
}
// Use sdk.bot.username to construct inline links
const botUsername = sdk.bot.username;
sdk.log.info(`Inline bot ready: @${botUsername}`);
// e.g. "Use @ton_ticker_bot TON to get Toncoin prices"
sdk.bot.onInlineQuery(async (ctx) => {
// ...
// Reference username in message text:
// `Share via @${sdk.bot.username}`
});
};Note: The ButtonDef interface supports a copy?: string field for native copy-to-clipboard buttons. When pressed, the specified text is copied to the user's clipboard without triggering a callback. This is ideal for wallet addresses or invite links:
keyboard: [
[
{ text: "Refresh", callback: "refresh:TON", style: "primary" },
{ text: "View Chart", url: "https://tonviewer.com/..." },
],
[
// No callback fired — text is copied directly to clipboard
{ text: "Copy Address", copy: "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs" },
],
]Step 2: Inline Query Handler
The inline query handler is called whenever a user types @yourbot query in any Telegram chat. We will search for tokens and return price results.
// Token database for lookup
const TOKENS: { symbol: string; name: string; address: string }[] = [
{ symbol: "TON", name: "Toncoin", address: "ton" },
{ symbol: "USDT", name: "Tether USD", address: "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs" },
{ symbol: "STON", name: "STON.fi", address: "EQA2kCVNwVsil2EM2mB0SkXytxCqQjS4mttjDpnXmwG9T6bO" },
{ symbol: "SCALE", name: "Scaleton", address: "EQBlqsm144Dq6SjbPI4jjZvlSHrzpwJKnL7MhRuRg1hIl-eH" },
];
sdk.bot.onInlineQuery(async (ctx: InlineQueryContext): Promise<InlineResult[]> => {
const query = ctx.query.toLowerCase().trim();
// Filter tokens matching the query
const matches = query
? TOKENS.filter(
(t) =>
t.symbol.toLowerCase().includes(query) ||
t.name.toLowerCase().includes(query)
)
: TOKENS; // Show all if empty query
// Fetch prices for matching tokens
const results: InlineResult[] = [];
for (const token of matches.slice(0, 10)) {
let priceText: string;
let description: string;
if (token.address === "ton") {
const price = await sdk.ton.getPrice();
priceText = price ? `$${price.usd.toFixed(2)}` : "Price unavailable";
description = `Toncoin: ${priceText}`;
} else {
const price = await sdk.ton.getJettonPrice(token.address);
priceText = price ? `$${price.usd.toFixed(6)}` : "Price unavailable";
description = `${token.name}: ${priceText}`;
}
results.push({
id: token.symbol.toLowerCase(),
type: "article",
title: `${token.symbol} ${priceText}`,
description,
content: {
text: `${token.symbol} (${token.name})\nPrice: ${priceText}`,
parseMode: "HTML",
},
keyboard: [
[
{ text: "Refresh Price", callback: `refresh:${token.symbol}`, style: "primary" },
{ text: "View Chart", url: `https://tonviewer.com/${token.address}` },
],
[
{ text: "Copy Address", copy: token.address },
],
],
});
}
return results;
});Step 3: Styled Keyboard
The Bot SDK supports three button colors via GramJS Layer 222. Colors are specified with the style property and render natively in Telegram clients that support Layer 222.
// You can also build keyboards outside of inline results
// using sdk.bot.keyboard() for reuse
const priceKeyboard = sdk.bot.keyboard([
[
{ text: "Refresh", callback: "refresh:TON", style: "primary" }, // Blue
{ text: "Buy", callback: "buy:TON", style: "success" }, // Green
{ text: "Sell", callback: "sell:TON", style: "danger" }, // Red
],
[
{ text: "View on Tonviewer", url: "https://tonviewer.com" },
],
]);
// .toTL() returns GramJS MTProto markup (colored buttons)
const tlMarkup = priceKeyboard.toTL();
// .toGrammy() returns standard Bot API markup (no colors, graceful fallback)
const grammyMarkup = priceKeyboard.toGrammy();
// .rows gives you the raw ButtonDef[][] with prefixed callbacks
// e.g. "ton-ticker:refresh:TON" instead of "refresh:TON"
const rawRows = priceKeyboard.rows;When you specify keyboard inside an InlineResult object, the platform automatically builds the keyboard for you — you pass raw ButtonDef[][] and it handles the rendering. The sdk.bot.keyboard() method is useful when you need to build keyboards outside of inline results, for example when editing messages via editInlineMessage().
Step 4: Callback Handlers
Register callback handlers with glob patterns. The plugin namespace prefix is automatically stripped, so your patterns match against your raw callback data.
// Handle "Refresh Price" button — matches "refresh:TON", "refresh:USDT", etc.
sdk.bot.onCallback("refresh:*", async (ctx: CallbackContext) => {
const symbol = ctx.data.split(":")[1];
const token = TOKENS.find((t) => t.symbol === symbol);
if (!token) {
await ctx.answer("Token not found", true); // true = show as alert
return;
}
let priceText: string;
if (token.address === "ton") {
const price = await sdk.ton.getPrice();
priceText = price ? `$${price.usd.toFixed(2)}` : "Price unavailable";
} else {
const price = await sdk.ton.getJettonPrice(token.address);
priceText = price ? `$${price.usd.toFixed(6)}` : "Price unavailable";
}
const now = new Date().toLocaleTimeString("en-US", { hour12: false });
// Edit the inline message with updated price
await ctx.editMessage(
`${token.symbol} (${token.name})\nPrice: ${priceText}\nUpdated at ${now}`,
{
parseMode: "HTML",
keyboard: [
[
{ text: "Refresh Price", callback: `refresh:${token.symbol}`, style: "primary" },
{ text: "View Chart", url: `https://tonviewer.com/${token.address}` },
],
[
{ text: "Copy Address", copy: token.address },
],
],
}
);
await ctx.answer(`${token.symbol}: ${priceText}`);
});
// Handle buy/sell actions (example — these would trigger your trading logic)
sdk.bot.onCallback("buy:*", async (ctx: CallbackContext) => {
const symbol = ctx.data.split(":")[1];
await ctx.answer(`Buy ${symbol} — use /buy command in a direct chat with the bot`, true);
});
sdk.bot.onCallback("sell:*", async (ctx: CallbackContext) => {
const symbol = ctx.data.split(":")[1];
await ctx.answer(`Sell ${symbol} — use /sell command in a direct chat with the bot`, true);
});Step 5: Chosen Result Handler
Track when users select an inline result. This is useful for analytics and for updating the message after the user sends it.
sdk.bot.onChosenResult(async (ctx: ChosenResultContext) => {
sdk.log.info(
`User chose "${ctx.resultId}" from query "${ctx.query}" ` +
`(inline msg: ${ctx.inlineMessageId ?? "N/A"})`
);
// Optionally update the message immediately after selection
// to add a "Shared by @username" footer
if (ctx.inlineMessageId) {
const token = TOKENS.find((t) => t.symbol.toLowerCase() === ctx.resultId);
if (token) {
let priceText: string;
if (token.address === "ton") {
const price = await sdk.ton.getPrice();
priceText = price ? `$${price.usd.toFixed(2)}` : "N/A";
} else {
const price = await sdk.ton.getJettonPrice(token.address);
priceText = price ? `$${price.usd.toFixed(6)}` : "N/A";
}
await sdk.bot.editInlineMessage(
ctx.inlineMessageId,
`${token.symbol} (${token.name})\nPrice: ${priceText}\n\nShared via @${sdk.bot.username}`,
{
parseMode: "HTML",
keyboard: [
[
{ text: "Refresh", callback: `refresh:${token.symbol}`, style: "primary" },
{ text: "View Chart", url: `https://tonviewer.com/${token.address}` },
],
],
}
);
}
}
});Step 6: Complete Plugin Code
Here is the entire plugin in one file, ready to copy into your plugins directory:
import type {
PluginSDK,
SimpleToolDef,
PluginManifest,
InlineResult,
InlineQueryContext,
CallbackContext,
ChosenResultContext,
} from "@teleton-agent/sdk";
export const manifest: PluginManifest = {
name: "ton-ticker",
version: "1.0.0",
description: "Inline TON price ticker — search tokens and share prices in any chat",
bot: {
inline: true,
callbacks: true,
rateLimits: {
inlinePerMinute: 60,
callbackPerMinute: 120,
},
},
};
const TOKENS: { symbol: string; name: string; address: string }[] = [
{ symbol: "TON", name: "Toncoin", address: "ton" },
{ symbol: "USDT", name: "Tether USD", address: "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs" },
{ symbol: "STON", name: "STON.fi", address: "EQA2kCVNwVsil2EM2mB0SkXytxCqQjS4mttjDpnXmwG9T6bO" },
{ symbol: "SCALE", name: "Scaleton", address: "EQBlqsm144Dq6SjbPI4jjZvlSHrzpwJKnL7MhRuRg1hIl-eH" },
{ symbol: "BOLT", name: "Huebel Bolt", address: "EQD0vdSA_NedR9uvbgN9EikRX-suesDxGeFg69XQMavfLqIw" },
{ symbol: "GRAM", name: "Gram", address: "EQC47093oX5Xhb2xiBSdRGOEpANw3G7YDE0Fys6EoSDq9J1Y" },
];
async function getTokenPrice(
sdk: PluginSDK,
token: { symbol: string; address: string }
): Promise<string> {
if (token.address === "ton") {
const price = await sdk.ton.getPrice();
return price ? `$${price.usd.toFixed(2)}` : "Price unavailable";
}
const price = await sdk.ton.getJettonPrice(token.address);
return price ? `$${price.usd.toFixed(6)}` : "Price unavailable";
}
export const start = async (sdk: PluginSDK) => {
if (!sdk.bot) {
sdk.log.warn("Bot SDK not available — skipping inline setup");
return;
}
// ── Inline Query Handler ───────────────────────────────────────
sdk.bot.onInlineQuery(async (ctx: InlineQueryContext): Promise<InlineResult[]> => {
const query = ctx.query.toLowerCase().trim();
const matches = query
? TOKENS.filter(
(t) =>
t.symbol.toLowerCase().includes(query) ||
t.name.toLowerCase().includes(query)
)
: TOKENS;
const results: InlineResult[] = [];
for (const token of matches.slice(0, 10)) {
const priceText = await getTokenPrice(sdk, token);
results.push({
id: token.symbol.toLowerCase(),
type: "article",
title: `${token.symbol} ${priceText}`,
description: `${token.name}: ${priceText}`,
content: {
text: `${token.symbol} (${token.name})\nPrice: ${priceText}`,
parseMode: "HTML",
},
keyboard: [
[
{ text: "Refresh Price", callback: `refresh:${token.symbol}`, style: "primary" },
{ text: "View Chart", url: `https://tonviewer.com/${token.address}` },
],
[
{ text: "Copy Address", copy: token.address },
],
],
});
}
return results;
});
// ── Callback Handlers ──────────────────────────────────────────
// Refresh price button
sdk.bot.onCallback("refresh:*", async (ctx: CallbackContext) => {
const symbol = ctx.data.split(":")[1];
const token = TOKENS.find((t) => t.symbol === symbol);
if (!token) {
await ctx.answer("Token not found", true);
return;
}
const priceText = await getTokenPrice(sdk, token);
const now = new Date().toLocaleTimeString("en-US", { hour12: false });
await ctx.editMessage(
`${token.symbol} (${token.name})\nPrice: ${priceText}\nUpdated at ${now}`,
{
parseMode: "HTML",
keyboard: [
[
{ text: "Refresh Price", callback: `refresh:${token.symbol}`, style: "primary" },
{ text: "View Chart", url: `https://tonviewer.com/${token.address}` },
],
[
{ text: "Copy Address", copy: token.address },
],
],
}
);
await ctx.answer(`${token.symbol}: ${priceText}`);
});
// ── Chosen Result Handler ──────────────────────────────────────
sdk.bot.onChosenResult(async (ctx: ChosenResultContext) => {
sdk.log.info(
`User chose "${ctx.resultId}" from query "${ctx.query}" ` +
`(inline msg: ${ctx.inlineMessageId ?? "N/A"})`
);
if (ctx.inlineMessageId) {
const token = TOKENS.find((t) => t.symbol.toLowerCase() === ctx.resultId);
if (token) {
const priceText = await getTokenPrice(sdk, token);
await sdk.bot.editInlineMessage(
ctx.inlineMessageId,
`${token.symbol} (${token.name})\nPrice: ${priceText}\n\nShared via @${sdk.bot.username}`,
{
parseMode: "HTML",
keyboard: [
[
{ text: "Refresh", callback: `refresh:${token.symbol}`, style: "primary" },
{ text: "Chart", url: `https://tonviewer.com/${token.address}` },
],
],
}
);
}
}
});
sdk.log.info(`TON Ticker inline bot ready: @${sdk.bot.username}`);
};
// Optional: expose a tool for direct chat usage
export const tools = (sdk: PluginSDK): SimpleToolDef[] => [
{
name: "ticker_price",
description: "Get current price for a TON token",
parameters: {
type: "object",
properties: {
symbol: { type: "string", description: "Token symbol (e.g. TON, USDT, STON)" },
},
required: ["symbol"],
},
async execute(params) {
const token = TOKENS.find(
(t) => t.symbol.toLowerCase() === params.symbol.toLowerCase()
);
if (!token) {
return { error: `Unknown token: ${params.symbol}. Available: ${TOKENS.map((t) => t.symbol).join(", ")}` };
}
const priceText = await getTokenPrice(sdk, token);
return {
symbol: token.symbol,
name: token.name,
price: priceText,
address: token.address,
inlineUsage: `Type @${sdk.bot?.username ?? "yourbot"} ${token.symbol.toLowerCase()} in any chat`,
};
},
},
];Testing
- Place the plugin in
plugins/ton-ticker/and restart the agent. - Open any Telegram chat and type
@yourbotfollowed by a space. - You should see a list of all supported tokens with their current prices.
- Type
@yourbot tonto filter results to Toncoin. - Select a result to share it in the chat.
- Press the blue "Refresh Price" button to update the price in place.
- Press "Copy Address" to copy the token contract address to your clipboard.
- In a direct message to the bot, try asking: "What's the price of STON?" to test the agent tool.
Gotchas
| Issue | Explanation |
|---|---|
sdk.bot is null |
Your manifest must declare bot: { inline: true }. Without this, the platform does not initialize the Bot SDK for your plugin. |
| Callback data auto-prefixed | If your plugin is "ton-ticker" and you set callback: "refresh:TON", the actual data sent to Telegram is "ton-ticker:refresh:TON". This is stripped back before your handler sees it, so match against "refresh:*", not "ton-ticker:refresh:*". |
Colored buttons only with .toTL() |
Button styles ("success", "danger", "primary") only render on Telegram clients that support GramJS Layer 222 (MTProto). When using .toGrammy() (Bot API), colors are silently ignored and buttons appear in the default style. |
| Rate limits exceeded | If your bot gets rate-limited, handlers are silently skipped. The user sees no inline results or no callback response. Increase rateLimits in the manifest or add caching to reduce API calls. |
onChosenResult never fires |
You must enable inline feedback via @BotFather (/setinlinefeedback). Without this, Telegram does not send chosen result updates. |
Register handlers in start(), not tools() |
The tools() function is called to register agent tools. Bot event handlers must be registered in the start() lifecycle hook, which runs once when the plugin starts. |
editInlineMessage fails |
You can only edit inline messages that were sent via your bot. The inlineMessageId comes from CallbackContext or ChosenResultContext. |