Status: Implemented in 0.10.0. Stable public API.
Meta-contract: transportable (bit-for-bit identical across projects), high-density (zero fluff), constant (evolve by crystallizing, not speculating), optimal minimum (add only when it hurts).
Telegram Extension Sections let ordinary pi extensions add structured UI surfaces to the pi-telegram inline application menu. The platform mirrors Pi's own extensibility model: small, composable extensions that plug into a shared shell without owning transport, polling, authorization, or menu lifecycle.
pi-telegram stays the single bot operator. Extensions register typed sections; the bridge handles Telegram UI rendering, callback routing, token mapping, navigation hierarchy, and diagnostics. Section views default to explicit Telegram HTML UI markup, while extensions can request Markdown or plain text when that better matches their content. No second polling loop, no new loader — just one registerTelegramSection() call.
The standard operates across three integration surfaces:
- Extension API: registration shape, context ports,
callbackData(),getLabel(), navigation, disposer - Telegram Bot API: 64-byte limit → token mapping, inline keyboard,
menu:back/settings:listrouting, stale-token answers - Pi Extension API: typed import +
globalThis,pi.on("shutdown")cleanup, load-order, identity
Each section has one stable identity key. Use the same rules as the Extension Locks Standard:
package.json/namefor npm-style pi packages- Directory name when the entrypoint is
index.tswithoutpackage.json - File basename for single-file extensions
extensions/pi-telegram-extension-demo/package.json name=@llblab/pi-telegram-extension-demo → @llblab/pi-telegram-extension-demo
extensions/pi-telegram-extension-demo/index.ts without package.json → pi-telegram-extension-demo
extensions/pi-telegram-extension-demo.ts → pi-telegram-extension-demo
The id is the owner identity. No separate owner field. Used for registry ownership, conflict detection, diagnostics, cleanup, and callback routing lookup.
import { registerTelegramSection } from "@llblab/pi-telegram/sections";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
const unregister = registerTelegramSection({
id: "@llblab/pi-telegram-extension-demo",
label: "🧪 Demo submenu",
order: 10,
getLabel: () => `${flag ? "🟢" : "⚫️"} Demo submenu`,
render: async (ctx) => ({
text: "<b>Demo</b>",
parseMode: "html",
replyMarkup: {
inline_keyboard: [
[{ text: "Click me", callback_data: ctx.callbackData("act", "x") }],
],
},
}),
handleCallback: async (ctx) => {
if (ctx.action === "act") {
await ctx.answerCallback(`payload: ${ctx.payload}`);
return "handled";
}
return "pass";
},
settings: {
label: "🧪 Demo settings",
order: 0,
getLabel: () => `${flag ? "🟢" : "⚫️"} Demo settings`,
open: async (ctx) => ({ text: "<b>Settings</b>", parseMode: "html" }),
handleCallback: async (ctx) => {
flag = ctx.payload === "on";
return "handled";
},
},
});
pi.on("shutdown", () => unregister());
}type TelegramSectionId = string;
type TelegramSectionCallbackResult = "handled" | "pass";
interface TelegramSectionRegistration {
id: TelegramSectionId;
label: string;
order?: number;
getLabel?: () => string;
render: (
ctx: TelegramSectionContext,
) => TelegramSectionView | Promise<TelegramSectionView>;
handleCallback?: (
ctx: TelegramSectionCallbackContext,
) => TelegramSectionCallbackResult | Promise<TelegramSectionCallbackResult>;
settings?: {
label: string;
order?: number;
getLabel?: () => string;
open: (
ctx: TelegramSectionContext,
) => TelegramSectionView | Promise<TelegramSectionView>;
handleCallback?: (
ctx: TelegramSectionCallbackContext,
) => TelegramSectionCallbackResult | Promise<TelegramSectionCallbackResult>;
};
}
interface TelegramSectionView {
text: string;
// Defaults to "html" for explicit Telegram UI markup.
// Use "markdown" when the section naturally owns Markdown content.
parseMode?: "markdown" | "html" | "plain";
replyMarkup?: TelegramInlineKeyboardMarkup;
}const unregister = registerTelegramSection(section);
unregister(); // removes from main menu, settings, and callback routingTwo paths, same registry:
Typed import (preferred): Extension imports registerTelegramSection from @llblab/pi-telegram/sections. The function reads from a globalThis registry set by pi-telegram at startup. In 0.12.0, package-private @llblab/pi-telegram/lib/*.ts deep imports are no longer exported.
Relative import (local): When the extension cannot resolve @llblab/pi-telegram as an npm package, use the public API membrane via a relative path:
import { registerTelegramSection } from "../pi-telegram/api/sections.ts";GlobalThis bridge (zero-coupling): pi-telegram exposes __piTelegramSectionRegistry__ on globalThis. The typed import is a thin wrapper. Extensions never touch the raw registry.
Load order: pi-telegram must load first (sets the global registry). Demo/consumer extensions load second (call registerTelegramSection). Pi's normal extension loader guarantees this when pi-telegram is listed first.
Shutdown: Call pi.on("shutdown", () => unregister()) to clean up your section. pi-telegram owns the registry for its loaded session, but it does not globally wipe extension registries on every session_shutdown.
Sections appear in two locations:
Section rows are injected before the ⚙️ Settings row. Ordered by order (lower first), then id alphabetically. The top-level getLabel() function (if present) is called on every render to produce a dynamic main-menu label — use it for extension status indicators.
🤖 Model: anthropic/claude-sonnet-4-5
🧠 Thinking: off
⌛ Queue: 0
🟢 Demo submenu ← extension section (dynamic label)
⚙️ Settings
Built-in core rows keep priority. Section errors do not break menu rendering — a failed dynamic label is omitted with a diagnostic entry until a later label render succeeds.
Extensions with a settings block inject rows before built-in Proactive push. The getLabel() function (if present) is called on every render to produce a dynamic label — use it for status indicators:
getLabel: () => `${flag ? "🟢" : "⚫️"} Demo settings`;⬆️ Main menu
🟢 Demo settings ← extension settings (dynamic label)
🟢 Proactive push
Ordered by settings.order (lower first), then id alphabetically.
Telegram limits callback_data to 64 bytes. Full npm names like @llblab/pi-telegram-explorer often exceed this. pi-telegram maps each registered section to a compact numeric token:
section:<token>:<action>:<payload>
Example: section:0:counter:5
The token is an implementation detail. Section authors never write section: strings manually. Use ctx.callbackData(action, payload?) which fills in the correct token and rejects callback data above Telegram's 64-byte limit.
- Telegram update arrives through the single
pi-telegrampolling loop - Update handlers observe/consume (raw update interception)
- Button action store (
tgbtn:*) - Compact confirmation callbacks (
compact:*) - Queue menu callbacks (
queue:*) - Settings menu callbacks (
settings:*) - Section callbacks (
section:*) - Built-in menu callbacks (
menu:*,model:*,thinking:*,status:*) - Unknown callbacks fall back to
[callback]prompt text
"handled": callback consumed, stop routing,answerCallbackQueryalready called"pass": section declines; fallback to settings handler (if exists), then to caller
section.handleCallback(ctx) → "handled" | "pass"
└─ if "pass" and settings.handleCallback exists →
settings.handleCallback(newCtx with backCallback="settings:list")
The fallback creates a new context with the correct backCallback for the navigation level.
If a section is unregistered or a token is unknown, the callback is answered with a short Telegram native popup:
"This section is no longer available."
Section render and callback errors are caught, surfaced as popup text, and stored in section diagnostics until the matching surface later succeeds. No unhandled exceptions leak to polling.
ctx.edit() automatically prepends a Back row for menu-bound views. The Back target depends on the navigation level:
- Section root (from main menu):
⬆️ Main menu→menu:back - Section sub-view (
ctx.edit()in handler):⬆️ Back→section:<token>:open - Settings root (from Settings list):
⬆️ Back→settings:list - Settings sub-view (
ctx.edit()in settings handler):⬆️ Back→settings:list
Section authors do not need to manage the Back button for ctx.edit() — it is added automatically and deduplicated when already present. ctx.open() sends a standalone chat message and does not prepend a Back row.
Main menu
├─ 🧪 Demo submenu ──── [⬆️ Main menu]
│ └─ Counter ─────── [⬆️ Back → Demo submenu]
└─ ⚙️ Settings ──────── [⬆️ Main menu]
└─ Demo settings ─ [⬆️ Back → Settings list]
└─ toggle ─── [⬆️ Back → Settings list] (via ctx.edit)
interface TelegramSectionContext {
sectionId: string;
chatId: number;
messageId?: number;
/** Answer the callback query with an optional popup text */
answerCallback(text?: string): Promise<void>;
/** Edit the current message (auto-prepends Back row) */
edit(view: TelegramSectionView): Promise<void>;
/** Send a standalone chat message without auto-navigation */
open(view: TelegramSectionView): Promise<void>;
/** Enqueue a plain-text prompt turn */
enqueuePrompt(prompt: string): Promise<void>;
/** Build a section-namespaced callback_data string */
callbackData(action: string, payload?: string): string;
/** Delete the message that triggered this callback */
deleteMessage(): Promise<void>;
}interface TelegramSectionCallbackContext {
sectionId: string;
chatId: number;
messageId?: number;
/** The action segment from callback_data */
action: string;
/** The payload segment from callback_data */
payload: string;
answerCallback(text?: string): Promise<void>;
edit(view: TelegramSectionView): Promise<void>;
open(view: TelegramSectionView): Promise<void>;
enqueuePrompt(prompt: string): Promise<void>;
callbackData(action: string, payload?: string): string;
/** Delete the message that triggered this callback */
deleteMessage(): Promise<void>;
}Queues a [telegram] <prompt> turn in the default lane with the paired user's chatId. Uses queueMutationRuntime.append() and triggers dispatchNextQueuedTelegramTurn(). The prompt arrives as a normal Telegram-owned turn — the agent sees it as if the user typed it.
Context ports are intentionally narrow. Sections cannot:
- Read/write filesystem
- Access raw process or bot clients
- Start a second polling loop
- Mutate session state
- Send arbitrary Telegram API calls
Add capability-specific ports only when the first real extension proves the need.
ctx.open() sends a new message directly into the Telegram chat — outside the menu hierarchy. No Back row is prepended. Use it for extension-driven interactions that live in the conversation:
- Confirmation dialogs ("Delete file.txt?")
- Approve/deny gates ("Allow tool execution?")
- Multi-step forms that should not be menu-bound
- Status reports with action buttons
handleCallback: async (ctx) => {
if (ctx.action === "delete-file") {
await ctx.open({
text: `<b>Delete ${ctx.payload}?</b>\n\nThis cannot be undone.`,
parseMode: "html",
replyMarkup: {
inline_keyboard: [
[
{
text: "✅ Yes, delete",
callback_data: ctx.callbackData("confirm-delete", ctx.payload),
},
{ text: "❌ Cancel", callback_data: ctx.callbackData("cancel") },
],
],
},
});
return "handled";
}
if (ctx.action === "confirm-delete") {
await ctx.deleteMessage();
await ctx.answerCallback(`Deleted: ${ctx.payload}`);
return "handled";
}
if (ctx.action === "cancel") {
await ctx.deleteMessage();
return "handled";
}
};ctx.deleteMessage() removes the dialog from chat after the user makes a choice. Callbacks from chat buttons route through the same handleCallback — the same ctx.callbackData() works regardless of where the button lives. The extension owns its callback namespace; the bridge owns transport.
Section callbacks use the section: prefix owned by pi-telegram:
section:0:open → open section root
section:0:settings:open → open settings root
section:0:<action>:<payload> → forwarded to handleCallback
section: is listed in TELEGRAM_OWNED_CALLBACK_PREFIXES alongside compact:, menu:, model:, settings:, status:, tgbtn:, thinking:, queue:. Layered extensions must not use this prefix.
Section views return TelegramSectionView.replyMarkup — a standard TelegramInlineKeyboardMarkup. The bridge prepends the Back row and sends the result through editMessageText / sendMessage with parse_mode: "HTML".
Button labels are not truncated by the bridge. Section authors should keep labels compact for mobile Telegram (under ~30 display-width cells). Use truncateTelegramButtonLabel for long dynamic text.
If the interactive message has expired (no stored model menu state), the callback receives:
"Interactive message expired."
This applies to section callbacks as well — the state check runs before dispatch.
The platform inherits from Pi's own extension model:
export default function(pi)→registerTelegramSection(section)pi.on("shutdown", ...)→ disposer fromregisterTelegramSection- Typed imports → typed import from
@llblab/pi-telegram/sections globalThisregistry →__piTelegramSectionRegistry__onglobalThis- Identity from
package.json/name→ same identity rules as Locks Standard - Narrow typed context ports →
TelegramSectionContext/TelegramSectionCallbackContext - Extension does not own transport →
pi-telegramowns polling, message lifecycle
getTelegramSectionDiagnostics() returns:
interface TelegramSectionDiagnostic {
id: string;
token: string;
label: string;
status: "active" | "error";
lastError?: string;
}Available programmatically via getTelegramSectionDiagnostics(). Main-menu/settings dynamic label failures, section render failures, and callback failures set status: "error" with lastError; the entry returns to active only after the matching label render, section render, or callback succeeds for that token. Section runtime state is not shown in Telegram status text; sections should surface user-facing state through dynamic button labels and their own submenus.
- File/project explorers
- Prompt/session history viewers
- Tool approval dashboards
- Runtime status panels
- Extension settings or diagnostics
- Human-in-the-loop forms that should not become agent turns
- Plain agent prompts (use normal queue)
- One-shot assistant-authored buttons (use
telegram_buttonoutbound comments) - Command-template pipelines (use inbound/outbound handlers)
- No second Telegram polling loop
- No new pi extension loader
- No generic webview system
- No default filesystem mutation API
- No prompt rollback semantics
- No separate
ownerfield while identity key is sufficient
- Callback Namespaces: defines
section:as pi-telegram-owned prefix. Sections use namespaced callbacks but authors never hand-roll them - Updates: raw update interception for direct Telegram update access. Sections are the structured UI layer above
- Extension Locks (polling): same identity key rules (
package.json/name→ canonical id) - Command Templates: sections do not execute command templates by default. UI registration + callback routing, not shell execution
@llblab/pi-telegram-extension-demo (extensions/pi-telegram-extension-demo/) is the reference implementation:
- Main menu:
🧪 Demo submenu— enqueue prompt, answer callback, show info, interactive counter - Settings:
🧪 Demo settings— ON/OFF toggle with dynamicgetLabel()status indicator, enqueue from settings - Navigation: full Back/Main menu hierarchy across all three levels
Use it as a template for new section-based extensions.