Install a plugin without restarting the gateway: Hermes wires it up live


You install a plugin on your bot in a Telegram group. It brings a new slash command. You type it in the group, full of expectation — and the bot acts as if it heard nothing. The docs say: restart the gateway. Which means a streaming answer gets cut off, background jobs die, and every session starts over. That is a heavy price for one slash command.

So most people wait for the next restart. That just changed (#119266, fixing #87770): install a plugin while the gateway is running, and its platform handlers get wired on the spot.

Why a restart used to be mandatory

The whole problem sat in when plugins are discovered:

  • discover_plugins() short-circuits once discovery has happened, unless you explicitly pass force=True.
  • None of the mid-run install paths passed it. Worse, tools/mcp_tool_config._portable_mcp_servers called it non-forced — so running mcp.reload after a mid-run install reloaded the old server set.
  • The TUI’s plugins.manage install returned as soon as the install helper finished, with no rescan at all.
  • Platform adapters (Telegram, Slack, …) built their handler registry at boot and never looked again.

Net effect: the plugin’s files were on disk, the config had it enabled, and the running process knew nothing about it.

An activation summary: live now vs next session

Every mid-run load path is now a real forced rescan, and it emits a callback from inside that rescan (PluginManager.on_plugin_loaded) carrying one activation summary per newly loaded plugin. It looks like this:

{"name": "late-mcp", "key": "late-mcp",
 "activated_now": {"gateway_commands": ["late"], "callbacks": ["telegram"]},
 "deferred": {"tools": ["late_tool"], "prompt": ["late.section"], "mcp_servers": ["worker"]}}

In plain terms:

Category When it applies What it covers
Gateway slash commands Immediately the command names the plugin registered
Gateway transform hooks Immediately transform_llm_output, transform_tool_result, transform_terminal_output, pre_gateway_dispatch, gateway_platform_event, pre_command
Other hooks Immediately the remaining hook names
Platform callbacks Immediately platforms with a register_platform_handler factory, plus Slack action ids
Tools Next session tool names
System-prompt sections Next session section ids
Portable MCP servers An mcp.reload, or next session the names exactly as they appear in mcp.json

The most useful of the three “immediately” rows is platform callbacks: a plugin’s button callbacks and inbound message transforms now work without a restart.

Why the wiring neither duplicates nor loses to core handlers

“Works immediately” sounds simple; two traps make it less so.

Trap one: double registration. A force reload hands back new function objects, so function identity cannot tell you whether something was already wired. The fix is to key each wiring on (plugin, factory qualname) — one plugin, one factory, one wiring. That is why the method is called idempotent re-wiring (rewire_plugin_handlers()): it re-reads the factory registry and runs only the factories not yet wired onto the live native client.

Trap two: ordering. On Telegram, newly added handlers are hoisted ahead of core’s catch-alls. This matters because PTB dispatches only the first match per group, and core registers a catch-all filters.COMMAND — a late handler sitting behind it would never fire. Slack does the equivalent by de-duplicating register_slack_action_handler per AsyncApp.

The gateway runner subscribes per served profile at boot, and on the event re-wires every live adapter on the event loop. All of this happens inside the gateway process, not in whichever process ran the install.

Every install surface tells you the truth

CLI, Desktop, TUI or the dashboard’s REST install — all of them report exactly what is live now and what waits:

hermes plugins install late-mcp --enable

The command first nudges the running gateway (a reload-plugins control-socket verb asking it to force-rescan the requested home), then prints that live-vs-deferred split. The Desktop/TUI plugins.manage install/toggle/update returns activation plus gateway_reloaded; restart_required is true only when no gateway answered.

There is a deliberate editorial stance in that design: better to do nothing than to pretend. Which is why:

  • Tools and system-prompt sections of a late-loaded plugin do not apply to the running session. Same rule as /skills install — a live session’s prompt and tool schema stay cache-stable, and a mid-flight change would break that.
  • Portable MCP servers wait for mcp.reload.
  • Disabling does not un-wire. Disable a plugin mid-run and its already-wired handlers stay until the gateway restarts. Every surface says so, rather than letting you believe it is instantly clean.

What it looks like in practice

A typical run:

# 1. install and enable (nudges the running gateway to rescan)
hermes plugins install late-mcp --enable

# 2. the output states that gateway_commands: ["late"] is live now
#    so /late already works in your Telegram group — no restart

# 3. if the plugin also ships an MCP server, bring that up too
#    (via mcp.reload, or just start a new session)

One caveat worth internalising: the gateway must be serving the home where the plugin lives. The event propagates per profile, and a gateway that does not serve that profile will not wire for it. On multi-profile and multi-gateway deployments this is easier to trip over than on a single machine — background in one gateway per host.

Boundaries and caveats

  • This release covers handlers only. Tools, system-prompt sections and portable MCP servers stay outside the immediate set, for the prompt-cache and connection-lifetime reasons above.
  • There is no un-wire. Wiring is one-directional: an install applies immediately; a disable only truly cleans up after a restart. Upgrades behave the same way.
  • Plugin trust did not loosen. This is a runtime wiring mechanism, not a relaxation of review; for where plugins come from and how they are admitted, see the plugin catalog trust model and the desktop plugin SDK.
  • Terminal-backend plugins are a separate track — see terminal backend plugins.

Wrapping up

“Just restart after installing” is survivable on a toy setup, but on a gateway doing real work it is a hard gate — it pushes every upgrade to 3am. Half of that gate is now gone: what should take effect immediately (platform handlers, slash commands, transform hooks) does, and what must wait a round (tools, prompt sections, MCP connections) says so out loud — including the fact that disabling does not un-wire. The other half will not move soon, because it runs straight into the prompt-cache invariant.