Instant inline keyboard menu for OpenClaw Telegram. Sub-second response — no LLM turn needed.
/menu → instant buttons. Every tap → instant response. The agent never wakes up for menu interactions.
Registers a /menu command and a menu:* interactive handler at the OpenClaw gateway level. When you type /menu in Telegram, the plugin responds directly with an inline keyboard — categories, submenus, skill launchers. Button callbacks are handled the same way. Zero LLM latency.
- ⚡ LLM-bypass — command and callbacks handled at gateway, not routed through the agent
- 📊 Dynamic categories — "Recent" and "Top Used" populated from skill-tracker usage data
- 🗂️ Submenus — Content & Writing, Infrastructure, Memory & Recall, Notes & Docs, Development, Search & Tools
- 🎯 Skill launcher — tapping a skill button fires the corresponding
/skill-namecommand - 🔒 No agent context cost — menu interactions don't consume tokens or context window
# Clone into your OpenClaw extensions directory
cd ~/.openclaw/extensions
git clone https://github.com/OctavianTocan/openclaw-menu-handler.git
cd openclaw-menu-handler
pnpm install
pnpm build
# Add to your openclaw.json plugins
# "plugins": { "allow": ["menu-handler"] }
# Restart the gateway
openclaw gateway restartType /menu in any Telegram chat connected to your OpenClaw agent. Tap buttons to navigate submenus or launch skills.
Menu items are loaded from a menu.json file in the plugin directory. Copy the example and edit it:
cp menu.example.json menu.jsonThe config file defines a title and an array of categories, each with a label, key, and button items:
{
"title": "🤖 My Agent",
"categories": [
{
"label": "🔧 Tools",
"key": "tools",
"items": [
{ "text": "My Skill", "command": "/my-skill" },
{ "text": "Other", "command": "/other" }
]
}
]
}menu.json is gitignored so your personal commands stay local. The "Recent" and "Top Used" dynamic categories are always available regardless of config.
Telegram native commands read buttons from result.channelData?.telegram?.buttons, not result.interactive. This was discovered through source-level debugging — having interactive in the response actively prevents button delivery because OpenClaw's isEditableTelegramProgressResult() returns false when it detects that field.
Button format: Array<Array<{ text: string, callback_data: string }>> — rows of buttons, matching Telegram's inline_keyboard schema.
Registered under the menu namespace. Callbacks (menu:content, menu:back, menu:recent, etc.) are resolved by the handler using ctx.respond.editMessage(), which edits the existing message in-place — no message spam.
Reads ~/.openclaw/skill-usage.db (JSONL, written by the skill-tracker plugin) to populate the "Recent" and "Top Used" dynamic categories. No database dependency — just line-delimited JSON.
pnpm testThe test suite uses Vitest and runs entirely in Node without any external dependencies.
| File | What it covers |
|---|---|
test/skill-usage.test.ts |
Core JSONL parsing, deduplication, sorting, limits |
test/skill-usage-extended.test.ts |
Field fidelity, multi-agent data, path override API, large DB (10k entries), edge cases |
test/menus.test.ts |
itemsToButtons, buildMenuFromConfig, fallback menus, dynamic submenus |
test/menus-extended.test.ts |
Unicode titles, empty categories, even/odd pairing, submenu isolation, 20-category stress |
test/handler.test.ts |
Core handler routing, plugin registration, /menu command shape |
test/handler-extended.test.ts |
Group/forum context, repeated calls, response shape contract, /menu button delivery invariants |
All tests use temp directories and isolated skill DB overrides — they leave no trace on your real ~/.openclaw/skill-usage.db.
One test specifically guards the known Telegram button-delivery regression: it asserts that the /menu command result never has a result.interactive field, because its presence causes OpenClaw's isEditableTelegramProgressResult() to return false and silently skip button injection.
- OpenClaw ≥ 2026.3.24
- Node.js ≥ 20
- Telegram channel configured