#Hook only plugin before_dispatch not firing on beta.5

1 messages · Page 1 of 1 (latest)

loud coyote
#

@fallen dome hook-only plugin before_dispatch not firing on beta.5
Running 2026.5.10-beta.5. Plugin loads clean (2 plugins: router-plugin, telegram), allowConversationAccess: true in config, typedHooks: [{ name: "before_dispatch" }] in inspect output — but capabilityMode: "none", capabilityCount: 0, hookNames: []. Commands reach workspace hooks but before_dispatch never intercepts.
Compatibility note says "hook-only — has not migrated to explicit capability registration yet." Does hook-only before_dispatch still fire automatically in beta.5, or does it need explicit capability registration now? If so, what's the migration path?

fallen domeBOT
#

I checked current source/docs on this one. Short version: hook-only before_dispatch should still fire automatically. capabilityMode: "none" / capabilityCount: 0 is not supposed to disable typed hooks; it just means the plugin is legacy hook-only. typedHooks: [{ name: "before_dispatch" }] is the thing that matters.

A couple gotchas:

  • hookNames: [] is not the typed-hook list, so don’t use that as the signal.
  • allowConversationAccess: true is required for the raw conversation/model hooks like llm_input, before_agent_run, agent_end, etc. In current source, before_dispatch is not in that conversation-access gate list. Keeping it set is fine, but it isn’t the migration lever here.
  • before_dispatch only runs on the normal reply/model-dispatch path. If the message is consumed earlier as a slash/native command or an inbound_claim, there may be no dispatch for it to intercept.

Migration path for this hook is still just typed hook registration:

api.on("before_dispatch", async (event, ctx) => {
  return { handled: true, text: "handled" };
});

No explicit capability registration should be needed unless your plugin is actually becoming a channel/provider/tool/etc.

#

One thing that smells relevant: current changelog has a fix for externally installed plugins losing message_received, before_dispatch, and reply hooks after scoped startup plugin loads: “Plugins/hooks: refresh live Gateway runtime hooks before inbound channel dispatch” / fixes #71167. So if a normal non-command prompt still doesn’t hit your hook on 2026.5.10-beta.5, I’d treat that as likely a beta regression or missing patch rather than a required capability migration. Please file it with your openclaw plugins inspect router-plugin --json plus a small plugin snippet: https://github.com/openclaw/openclaw/issues

Docs refs: https://docs.openclaw.ai/plugins/hooks and https://docs.openclaw.ai/plugins/architecture#plugin-shapes

loud coyote
#

@fallen dome Does fix #71167 ("refresh live Gateway runtime hooks before inbound channel dispatch") land in beta.5 or is it queued for a later build?

fallen domeBOT
#

Yes — I checked the published npm tarball for openclaw@2026.5.10-beta.5, and #71167 is in that build.

Evidence from the package:

  • CHANGELOG.md includes: “Plugins/hooks: refresh live Gateway runtime hooks before inbound channel dispatch… Fixes #71167.”
  • The built runtime has the reply.ensure_runtime_plugins step immediately before getGlobalHookRunner(), which is the actual fix path for refreshing the live hooks before dispatch.

So: not queued for later; beta.5 should have it. If you’re still seeing it fail on beta.5, I’d suspect either:

  1. the running Gateway didn’t restart onto beta.5, or
  2. this is a different edge case/regression beyond #71167.

Quick sanity check: run openclaw --version and restart the gateway after upgrading, then re-test with a plain non-command message.

loud coyote
#

@fallen dome before_dispatch hook never fires on beta.5 despite plugin loading correctly
openclaw plugins inspect router-plugin --runtime --json shows typedHooks: [{ name: "before_dispatch" }], allowConversationAccess: true, status: loaded, activated: true. Plugin appears in boot log as [router-plugin] loaded from index.js (pre-compiled) and in 2 plugins: router-plugin, telegram. #71167 confirmed present in beta.5 tarball per krill.
On any inbound message (slash command or natural language), workspace hooks fire normally but zero [router-plugin] log lines appear. before_dispatch handler never executes.
Gateway: 2026.5.10-beta.5 (1ba6893). Plugin installed via --link --dangerously-force-unsafe-install. inspect --runtime --json output attached.

fallen domeBOT
#

Thanks, that’s enough detail to narrow it down. Given typedHooks: [{ name: "before_dispatch" }], loaded, activated, and beta.5 includes #71167, this should fire for natural-language inbound messages. Slash commands can bypass the normal dispatch path, but plain text should hit it before the model path.

So I’d classify this as a likely beta.5 bug / missed live-registry edge case, not a migration issue.

The important distinction: plugins inspect --runtime proves the plugin can register the typed hook in a runtime registry. It does not necessarily prove the live getGlobalHookRunner() used by inbound dispatch currently contains that hook. Your symptoms point exactly there: the plugin is loaded, but the dispatch-time hook runner either has no before_dispatch handlers or is using a different active registry.

Two useful quick checks before filing:

api.on("message_received", async () => {
  console.log("[router-plugin] message_received fired");
});

api.on("before_dispatch", async () => {
  console.log("[router-plugin] before_dispatch fired");
  return undefined;
#
});

Then send a normal non-command message.

  • If neither fires: live dispatch hook runner is missing the plugin registry.
  • If message_received fires but before_dispatch doesn’t: dispatch path is short-circuiting before model dispatch.
  • If slash commands don’t fire: expected-ish; test plain text.

Please file this with the inspect JSON + those two-hook results: https://github.com/openclaw/openclaw/issues

Workaround if you need interception right now: try inbound_claim instead of before_dispatch for pre-agent routing/handling. It runs earlier in the inbound pipeline and is a better fit for “router intercepts messages before the model” anyway. 🦞

loud coyote
#

@fallen dome inbound_claim API shape for slash command routing in beta.5
Confirmed: before_dispatch fires on natural language but not slash commands in beta.5. Migrating to inbound_claim as you suggested. What's the event/ctx shape for inbound_claim? Specifically:

How do I read the message text? (event.body? event.text? event.content?)
How do I read the sender chat ID?
What do I return to claim/suppress the message? ({ claimed: true }? { handled: true }?)
Does returning a claim prevent the agent from running, or do I need to do something else?

fallen domeBOT
#

Nice, that confirms the split: before_dispatch is normal model-dispatch only; native slash commands take the command path.

For inbound_claim, the beta.5 typed shape is:

api.on("inbound_claim", async (event, ctx) => {
  const text = event.content;        // best primary text
  const agentText = event.bodyForAgent;
  const rawBody = event.body;

  const channel = ctx.channelId;     // e.g. "discord"
  const chatId = ctx.conversationId; // conversation/thread/chat target
  const senderId = ctx.senderId;     // user/sender id
  const messageId = ctx.messageId;

  return { handled: true };          // claim + suppress agent
#
  // or: return { handled: true, reply: { text: "Handled" } };
  // or: return undefined / { handled: false } to let normal flow continue
});

Answers directly:

  • Message text: use event.content. Fallbacks are event.bodyForAgent, event.body, event.transcript.
  • Sender chat/conversation ID: use ctx.conversationId / event.conversationId for the chat/thread target. Use ctx.senderId / event.senderId for the person who sent it.
  • Claim return shape: { handled: true }, not { claimed: true }.
  • Yes: { handled: true } is terminal and prevents the agent/model path from running. Include reply: { text: "..." } if you want OpenClaw to send a synthetic reply through the normal final-delivery path.

Important beta.5 caveat: from the source I’m seeing, inbound_claim is primarily wired for plugin-owned conversation bindings in the dispatch path, not as a universal “catch every slash command globally” hook. So if your goal is slash command routing, the more direct/stable path is probably api.registerCommand(...): plugin commands bypass the LLM by design, get ctx.commandBody, ctx.args, ctx.senderId, ctx.from/to, ctx.messageThreadId, and return a reply payload. Return without continueAgent to suppress the agent; set continueAgent: true only if you intentionally want the agent to continue.

loud coyote
#

@fallen dome one more question: api.registerCommand shape — two questions

Subcommands: We have /project close [query], /project list, /project confirm, etc. Do we register /project once and parse ctx.commandBody ourselves, or register each subcommand separately (e.g. project:close)?
Conversation ID format: In 4.27 before_dispatch we strip telegram: prefix from ctx.conversationId to get the numeric chat ID for Bot API calls. Does ctx.conversationId in registerCommand ctx still have the telegram: prefix, or is it already the raw numeric ID?

fallen domeBOT
#

Yep — for api.registerCommand(...) in beta.5:

1. Subcommands
Register the top-level command once:

api.registerCommand({
  name: "project",
  description: "Project router",
  acceptsArgs: true,
  async handler(ctx) {
    const [subcommand = "", ...rest] = (ctx.args ?? "").trim().split(/\s+/);
    const query = rest.join(" ");

    switch (subcommand) {
      case "close": return closeProject(query, ctx);
#
      case "list": return listProjects(ctx);
      case "confirm": return confirmProject(ctx);
      default: return { text: "Usage: /project close <query> | /project list | /project confirm" };
    }
  },
});

Don’t use project:close — command names only allow letters/numbers/hyphen/underscore, and matching is on the first token (/project). If you want separate top-level commands, use names like /project-close, but for your shape /project close ..., parse ctx.args yourself.

2. Conversation/chat ID format
There is no ctx.conversationId on PluginCommandContext in beta.5. Use:

  • ctx.to = conversation/chat target-ish value
  • ctx.from = raw sender/source value
  • ctx.senderId = resolved sender identity
#
  • ctx.messageThreadId = Telegram topic / Discord thread id when available

For Telegram Bot API calls, don’t assume ctx.to is already raw numeric. It may be raw or internal-prefixed depending the route. Normalize defensively:

function telegramChatId(raw?: string) {
  return (raw ?? "")
    .trim()
    .replace(/^(telegram|tg):/i, "")
    .replace(/^group:/i, "")
    .replace(/:topic:\d+$/i, "");
}

const chatId = telegramChatId(ctx.to ?? ctx.from);
const messageThreadId = ctx.messageThreadId;
#

If you’re sending via Telegram Bot API into a topic, pass both chat_id: chatId and message_thread_id: ctx.messageThreadId.

loud coyote
#

@fallen dome Suppress "No response generated" from registerCommand handler
Our handlers call sendText() directly via Telegram Bot API and return undefined. OpenClaw sends "No response generated. Please try again." as a fallback after every command. What do we return from the handler to tell OpenClaw "handler took care of it, don't send anything"? Is there a { handled: true } equivalent for registerCommand?

fallen domeBOT
#

For api.registerCommand there’s no { handled: true } / silent-ack equivalent in beta.5.

The intended contract is: the handler returns a ReplyPayload, and OpenClaw delivers it. For example:

return { text: "Done" };

If the handler returns undefined / {} / only { continueAgent: false }, the command is treated as handled from the agent’s perspective, but Telegram’s native command wrapper currently sees “no renderable reply payload” and sends:

No response generated. Please try again.

So for Telegram native slash commands in beta.5, your practical options are:

  1. Preferred: don’t call sendText() directly; return the reply payload and let OpenClaw send it.
#
return { text: "Project closed." };
  1. If you must send via Bot API yourself, there isn’t a clean built-in suppress return today. Returning an invisible/zero-width text would only replace the fallback with another Telegram message, so I wouldn’t recommend it.

This is worth filing as a small SDK/channel bug/request: plugin commands need a first-class silent result, e.g. { handled: true } / { suppressReply: true }, especially for plugins that own their own transport send. https://github.com/openclaw/openclaw/issues

loud coyote
#

@fallen dome from clawsweeper (just as an update) github #80756, Codex review: keeping this open for maintainer follow-up; there is still a little grit to resolve.

Summary
This should stay open: current main still has no command-result contract for “handled with no OpenClaw reply,” and Telegram native commands intentionally fall back when a plugin command result has no renderable payload. The report is concrete, but the requested fix is a new public plugin SDK/channel behavior decision rather than a cleanup-close candidate.

Reproducibility: yes. from current source and tests: an undefined command handler result becomes an empty object, and Telegram native command delivery maps that to the fallback message. I did not run a live Telegram/Windows reproduction in this read-only review.

Next step
The report is concrete, but the fix changes a public plugin SDK result contract and Telegram command delivery semantics, so it should get owner/product review before a repair PR.

fallen domeBOT
#

Yep, that matches what we found. Thanks for the update — keeping #80756 open is the right call.

The key point is exactly that this isn’t just a Telegram bug: it needs a public command-result contract for “plugin handled this, emit no OpenClaw reply.” Until that exists, Telegram native command fallback is behaving consistently with current code, even if it’s awkward for transport-owning plugins.

For now I’d treat the safe workaround as: return a real ReplyPayload from registerCommand and let OpenClaw deliver it, unless the plugin can tolerate the fallback. 🦞

loud coyote
#

@fallen dome does OpenClaw's Telegram delivery path in 5.x use IPv4 forcing (family: 4) internally, or does it still use the dual-stack default? On Windows with Node 22 we need family: 4 on all Telegram calls to avoid Happy Eyeballs AggregateError — that's why we own our own transport.

fallen domeBOT
#

Short version: main / 5.x does not force IPv4 on the first/default Telegram path.

What I see in current source:

  • Telegram uses its own resolveTelegramTransport() / undici dispatcher for bot polling and outbound sends.
  • Default dispatcher is created with forceIpv4: false.
  • On Node 22, OpenClaw defaults to:
    • autoSelectFamily: true
    • dnsResultOrder: "ipv4first"
  • There is a sticky fallback dispatcher that uses connect.family = 4 and autoSelectFamily = false, but only after a recoverable transport failure / timeout path promotes fallback.

So if your Windows/Node 22 case needs family: 4 on every Telegram call from the first attempt, owning the transport still makes sense. OpenClaw’s built-in path is more “dual-stack / Happy Eyeballs-ish first, IPv4 fallback after failure,” not “always IPv4.”

Relevant config knobs I’d expect to help but not equal hard family: 4: