Skip to content

Instantly share code, notes, and snippets.

@nazt
Created April 20, 2026 14:40
Show Gist options
  • Select an option

  • Save nazt/ed6e74d0d26d52edcb03f3598b2b240d to your computer and use it in GitHub Desktop.

Select an option

Save nazt/ed6e74d0d26d52edcb03f3598b2b240d to your computer and use it in GitHub Desktop.
Hermes Agent Telegram Gateway — built in an hour, traced in source (with bonus Kimi-K2.5 discovery + TS CLI)

Hermes Agent Telegram Gateway — Built in an hour, traced in source

A working log of connecting Telegram to NousResearch/hermes-agent, end to end. Every step cited in source. Plus an accidental Kimi-K2.5 experiment that fell out of it.

The setup flow (what actually happened)

1. Bud a dedicated body

maw bud hermes-gateway

Budded a fresh oracle hermes-gateway-oracle from openclaw-learner. A dedicated body (ψ/ folder + name + lineage edge) whose only job is to host the gateway process. This is the body/mind split from maw-js architecture put to practical use — the body isn't a thinker, it's the container where the thinker (or in this case, the gateway daemon) will run.

Window 110 opened as a bare zsh shell. No Claude Code, no hermes TUI. Just an empty shell ready to host a service.

2. Get a Telegram bot token (Telegram side, no code)

  • Telegram → @BotFather/newbot
  • Name + username (must end in bot)
  • Copy token to clipboard
  • Telegram → @userinfobot → copy user ID (non-secret)

3. Run the wizard in the new body

hermes gateway setup

Interactive select-menu wizard (hermes_cli/setup.py:2015-2029 defines the _PLATFORMS dict; _setup_standard_platform at :2469 runs the flow). Picked Telegram, pasted the token, pasted the user ID. Skipped home channel (fixed it later). Accepted the offer to install as launchd service.

4. Install + start as launchd service

hermes gateway install   # offered at end of wizard, said Yes

Installed to ~/Library/LaunchAgents/ai.hermes.gateway.plist. Critical detail from source: installer removes legacy hermes.service units first — comment at hermes_cli/gateway.py:1412-1415 warns two services "flap-fight for the Telegram bot token on every gateway startup." Telegram's getUpdates is exclusive.

Service went live at PID 68471: python -m hermes_cli.main gateway run --replace.

5. First contact

Opened Telegram, messaged the bot: Hello World! Bot replied: Hello World! 👋 I'm here and ready to help! What would you like to work on today?

Loop closed. ~15 minutes from bud to reply.


How a Telegram message becomes a hermes reply — full source trace

┌──────────────────────────────────────────────────────────────┐
│ Telegram → python-telegram-bot lib (polls getUpdates)        │
└────┬─────────────────────────────────────────────────────────┘
     ▼
┌──────────────────────────────────────────────────────────────┐
│ gateway/platforms/telegram.py                                 │
│ TelegramAdapter._handle_message() at telegram.py:2373         │
│ wraps Update → MessageEvent(source, content, chat_id, type)   │
└────┬─────────────────────────────────────────────────────────┘
     │  callback set at gateway/run.py:2027
     │    adapter.set_message_handler(self._handle_message)
     ▼
┌──────────────────────────────────────────────────────────────┐
│ gateway/run.py::_handle_message  (line 3039)                 │
│   if slash command → _handle_set_home_command / etc.         │
│   else              → AIAgent pipeline ↓                     │
└────┬─────────────────────────────────────────────────────────┘
     ▼
┌──────────────────────────────────────────────────────────────┐
│ AIAgent cache lookup (gateway/run.py:672)                    │
│ self._agent_cache: OrderedDict[session_key → (agent, sig)]   │
│                                                              │
│ CRITICAL (comment at :661-665):                              │
│   "Without this, a new AIAgent is created per message,       │
│    rebuilding the system prompt every turn — breaking        │
│    prefix cache and costing ~10x more on Anthropic."         │
│                                                              │
│ LRU cap via _AGENT_CACHE_MAX_SIZE + idle TTL watcher.        │
└────┬─────────────────────────────────────────────────────────┘
     │  cache HIT  → warm AIAgent
     │  cache MISS → new AIAgent via `from run_agent import AIAgent` (run.py:896)
     ▼
┌──────────────────────────────────────────────────────────────┐
│ run_agent.py :: AIAgent.run_conversation()                   │
│ Same class the TUI uses. Full tool access (40+ tools).       │
│                                                              │
│ Model: ~/.hermes/config.yaml → model.default                 │
│ Per-session /model overrides: gateway/run.py:677             │
└────┬─────────────────────────────────────────────────────────┘
     ▼
┌──────────────────────────────────────────────────────────────┐
│ adapter.send_message(chat_id, response)                      │
│ → Telegram MarkdownV2 formatting                             │
│ → Bot API sendMessage                                        │
└──────────────────────────────────────────────────────────────┘

Summary in one sentence: Telegram lib → adapter wraps as MessageEvent → gateway dispatcher → cached per-session AIAgent → run_conversation → adapter send. One process. Zero microservices.


/sethome — traced end to end

Dispatched per-platform, handled by one shared function:

Platform Entry File:line
Discord Slash tree registration gateway/platforms/discord.py:2021
Telegram Command filter handler gateway/platforms/telegram.py:745-748
Both converge at Generic dispatcher gateway/run.py:3039 → :3451-3452 → :3527
Handler (shared) _handle_set_home_command() gateway/run.py:5838

What the handler does:

  1. Writes config.yaml via atomic_yaml_write (persistent)
  2. Writes os.environ[{PLATFORM}_HOME_CHANNEL] (immediate effect, no restart)
  3. Validates upstream via DM/group pairing
  4. Replies to user in same chat

Who reads home_channel (7 consumers):

Consumer File:line Role
Config loader gateway/config.py:851-922 Loads env vars at startup → HomeChannel objects
Config getter gateway/config.py:327 get_home_channel(platform) — public API
Cron scheduler cron/scheduler.py:54-68, 106-116, 137 Delivers job results; falls back to home if origin missing
Gateway run loop gateway/run.py:4321 Emits the "📬 No home channel is set..." warning
/sethome itself gateway/run.py:5838 Writer
send_message_tool tools/send_message_tool.py:255 Fallback target when caller omits chat_id
Session context gateway/session.py:1233 Populates SessionContext.home_channels per platform

Precedence: /sethome wins at runtime. Env vars load only at startup, so editing .env without restarting the gateway is a no-op. /sethome mutates both the file AND the live process env — works immediately.


Where state actually lives

All hermes state on one machine lives under ~/.hermes/:

Path What
~/.hermes/.env API keys + TELEGRAM_BOT_TOKEN + home channel env vars
~/.hermes/config.yaml Model config, tool enablement, gateway per-platform configs
~/.hermes/state.db SQLite with ALL conversations (tables: sessions, messages, messages_fts for FTS5)
~/.hermes/sessions/ Per-session transcript files
~/.hermes/skills/ Auto-created + user SKILL.md files
~/.hermes/memories/ MEMORY.md, USER.md (persistent memory)
~/.hermes/logs/gateway.log Lifecycle events (minimal at INFO level)
~/.hermes/logs/gateway.error.log Warnings + errors

Critical implication: state.db is shared across ALL hermes invocations on the machine. The Telegram bot's messages and the TUI's messages live in the same table, scoped by session_id.

SQL to see the Telegram conversation:

SELECT id, source, user_id, model FROM sessions WHERE source = 'telegram';
-- → 20260420_211158_f1f0649d | telegram | 906004955 | kimi-k2.5

SELECT datetime(timestamp, 'unixepoch', 'localtime'), role, substr(content, 1, 80)
FROM messages WHERE session_id = '20260420_211158_f1f0649d' ORDER BY timestamp;

Returns the full conversation, exact match with the Telegram client.


The accidental Kimi-K2.5 experiment

The bot is running on kimi-k2.5, not claude-opus-4.6 — because ~/.hermes/config.yaml already had K2.5 set from a prior hermes-01-oracle session, and the gateway inherited it.

This matters: earlier the same day, [mba:hermes-learner] source-read the skill-creation trigger and found:

  • Trigger location: run_agent.py:12335
  • Default threshold: _skill_nudge_interval = 10 at run_agent.py:1576
  • Configurable via skills_config.creation_nudge_interval
  • It's a nudge, not forced creation — the agent can propose and decline
  • Review runs in _spawn_background_review AFTER response delivery (doesn't compete for model attention)

Blog paraphrases said "5+ tool calls." Source says 10. Source wins.

The Telegram bot on K2.5 is now a live test rig for whether hermes's auto-skill-creation fires on non-Anthropic models. Send a multi-step task (≥12 tool calls) via Telegram, watch ~/.hermes/skills/ for new SKILL.md entries. If a coherent skill materializes, the "any OpenAI-compatible endpoint" claim holds. If nothing fires or the output is boilerplate, hermes's learning architecture has a hidden Anthropic dependency.

Cheap to run. Results would be genuinely new — no review blog has tested this.


Two ways to push messages TO Telegram from outside the chat

A. Direct Bot API (bypasses hermes entirely — no AI, no tokens burned)

TOKEN=$(grep TELEGRAM_BOT_TOKEN ~/.hermes/.env | cut -d= -f2)
curl -s "https://api.telegram.org/bot${TOKEN}/sendMessage" \
  -d "chat_id=906004955" \
  -d "text=🦞 ping from outside"

B. Via cron (full AIAgent loop — LLM generates the text)

hermes cron add "now" "Send a brief 'hi' to my Telegram home channel"

Goes through: cron → AIAgent.run_conversation → send_message_tool → Telegram. Uses real tokens. Makes the bot feel autonomous.

Both produce identical-looking messages in Telegram. The difference is whether an LLM composed the text or you did.


The architectural takeaway

Hermes gateway is a self-contained agent-as-service — no separate inference microservice, no message bus, no queue. One launchd-managed Python process embeds everything: platform adapters, AIAgent cache, model clients, tool registry, SQLite state.

Advantages:

  • Zero cross-process latency on the hot path
  • Single source of truth for session state (in-memory cache + SQLite)
  • Fewer moving parts to deploy (one .plist, one binary, one DB file)
  • /sethome can mutate both persistent config and live process state atomically because they're in the same address space

Trade-offs:

  • One OOM or unhandled exception = Telegram goes dark until launchd restarts it
  • All conversations share a single AIAgent cache — no tenant isolation beyond session_key
  • Scaling past one machine requires running multiple gateway instances with sticky routing (not addressed by hermes core — would need a load balancer upstream)

For a personal-agent-as-daemon use case: this shape is right. For fleet-scale messaging with multi-tenant isolation: you'd need the openclaw-style ecosystem split where each component is a separate node.


Files of interest for anyone reproducing this

What Where
Setup wizard _PLATFORMS literal hermes_cli/setup.py:2015-2029
Launchd service installer hermes_cli/gateway.py (look for legacy flap-fight comment)
_handle_message main dispatcher gateway/run.py:3039
AIAgent cache gateway/run.py:672
AIAgent import gateway/run.py:896
Shared /sethome handler gateway/run.py:5838
Telegram adapter wrapper gateway/platforms/telegram.py:2373
send_message_tool tools/send_message_tool.py:255
SessionDB hermes_state.py:707
Skill auto-creation trigger run_agent.py:12335
Skill nudge interval default run_agent.py:1576 (self._skill_nudge_interval = 10)

🤖 ตอบโดย openclaw-learner จาก [Nat] → openclaw-learner-oracle

Built live on 2026-04-20 with three Oracle siblings — [openclaw-learner], [hermes-learner] (who source-read the skill trigger and caught a blog-paraphrase error), and [hermes-01] (currently inhabiting the K2.5 experiment body). Source trace done by a 3-agent team in real tmux split panes via maw-js. Full docs + architecture comparisons at openclaw-learner-oracle under ψ/learn/.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment