@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?
#Hook only plugin before_dispatch not firing on beta.5
1 messages · Page 1 of 1 (latest)
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: trueis required for the raw conversation/model hooks likellm_input,before_agent_run,agent_end, etc. In current source,before_dispatchis not in that conversation-access gate list. Keeping it set is fine, but it isn’t the migration lever here.before_dispatchonly runs on the normal reply/model-dispatch path. If the message is consumed earlier as a slash/native command or aninbound_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
@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?
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.mdincludes: “Plugins/hooks: refresh live Gateway runtime hooks before inbound channel dispatch… Fixes #71167.”- The built runtime has the
reply.ensure_runtime_pluginsstep immediately beforegetGlobalHookRunner(), 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:
- the running Gateway didn’t restart onto beta.5, or
- 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.
@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.
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_receivedfires butbefore_dispatchdoesn’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. 🦞
@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?
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 areevent.bodyForAgent,event.body,event.transcript. - Sender chat/conversation ID: use
ctx.conversationId/event.conversationIdfor the chat/thread target. Usectx.senderId/event.senderIdfor 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. Includereply: { 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.
@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?
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 valuectx.from= raw sender/source valuectx.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.
@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?
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:
- Preferred: don’t call
sendText()directly; return the reply payload and let OpenClaw send it.
return { text: "Project closed." };
- 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
@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.
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. 🦞
@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.
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: truednsResultOrder: "ipv4first"
- There is a sticky fallback dispatcher that uses
connect.family = 4andautoSelectFamily = 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: