#handled:true on message:received has no effect

1 messages Β· Page 1 of 1 (latest)

ember wyvern
#

@silk dove Hey all β€” running into what looks like a framework-level issue with { handled: true } on message:received.

Short version: returning { handled: true } from a hook on message:received has no effect β€” the agent model fires anyway on every message regardless. Same behavior with { injectSystemContent } on that event type β€” the return value appears to be silently discarded.

For context, I'm building a router hook that intercepts specific commands (slash commands, project status queries, inbox checks) and sends responses directly via Telegram. The hook works correctly β€” it sends the right response β€” but the agent fires concurrently, reads whatever state the hook just modified, and sends its own response. The result is duplicate or contradictory messages.

Workaround I've landed on: fire any slow async work (like an inbox poll) without await so the hook sends first and wins the race. Behavioral rules in the boot-gate inject block suppress some of the agent output but compliance isn't 100%.

Is { handled: true } supposed to suppress the agent on message:received? If so, is this a known issue in 2026.4.12? If not, is there a supported mechanism for a hook to own a message entirely and prevent the agent from responding?

silk doveBOT
#

I checked current source, and this looks intentional, not just a race bug.

message:received in the internal hook system does not support claim semantics. Its return value is ignored. Same story for the plugin-side message_received hook, it is also fire-and-forget/void. In the dispatch path, both are kicked off, then OpenClaw continues into before_dispatch and normal agent handling.

So, in practice:

  • { handled: true } on message:received will not suppress the agent
  • { injectSystemContent } there will also be ignored
  • your await vs no-await behavior is just exposing that the hook is not in the decision path

The supported ways to own the message are:

  • before_dispatch: best fit for your router, intercept before model dispatch and return { handled: true, text? }
  • before_agent_reply: short-circuit right before the LLM call
  • before_prompt_build / before_agent_start: for prompt injection (prependContext, prependSystemContext, appendSystemContext, systemPrompt), not message_received

If you're using a plugin-bound conversation, inbound_claim is the earlier claim seam, but that is not the general HOOK.md message:received path.

ember wyvern
# silk dove I checked current source, and this looks intentional, not just a race bug. `mes...

@silk dove Yes please β€” that would be very helpful. My router handles slash commands (/pstatus, /project confirm, etc.), natural language project status queries, and inbox check queries. It sends responses directly via Telegram using the bot token. Main thing I need to know: if I return { handled: true } from before_dispatch, can the hook still send its own Telegram message, or does the response have to go through the text return field? And what does the event object look like β€” does it have the user's message text and chat ID available?

silk doveBOT
#

Yep, before_dispatch can do either of these:

  1. Send its own Telegram message, then return { handled: true }
  2. Let OpenClaw send the reply, by returning { handled: true, text: "..." }

If you already sent the Telegram message yourself, return just { handled: true }.
If you return text and send your own message, you'll create the same duplicate problem again.

The hook shape is basically:

api.on("before_dispatch", async (event, ctx) => {
  // event
  event.content;    // user text
  event.body;       // agent-facing/enriched body, if available
  event.channel;    // e.g. "telegram"
#
  event.sessionKey;
  event.senderId;
  event.isGroup;
  event.timestamp;

  // ctx
  ctx.channelId;      // e.g. "telegram"
  ctx.accountId;      // multi-account setups
  ctx.conversationId; // chat id-ish value
  ctx.sessionKey;
  ctx.senderId;

  return { handled: true };
});
#

A couple of useful details:

  • User message text is available, use event.content
  • Chat ID is available, use ctx.conversationId
  • For Telegram, ctx.conversationId is usually provider-prefixed, like telegram:-1001234567890, so if you're calling the Bot API directly you'll likely want to strip the telegram: prefix first
  • event.body is the richer version if preprocessing/transcript/link expansion happened, so event.body ?? event.content is a good default

Minimal pattern:

api.on("before_dispatch", async (event, ctx) => {
  const text = event.body ?? event.content;
  const conv = ctx.conversationId;

  if (!conv || !text) return;
#
  if (text.startsWith("/pstatus")) {
    const chatId = conv.replace(/^telegram:/, "");
    const reply = await buildProjectStatusReply(text);

    await sendTelegram(chatId, reply); // your own Bot API call
    return { handled: true };          // suppress OpenClaw agent reply
  }

  if (looksLikeInboxQuery(text)) {
    return {
      handled: true,
      text: await buildInboxReply(text), // let OpenClaw send this one
    };
  }
});
#

One important limitation: before_dispatch is pretty lean. You get conversationId, but not the richer message_received metadata set. So if you need exact provider messageId / thread metadata, this hook may be too small.

If you want, I can write a slightly more complete router example for:

  • slash commands
  • natural language status queries
  • inbox checks
ember wyvern
silk doveBOT
#

Yep, before_dispatch is plugin-only.

It is not part of the workspace HOOK.md internal hook system. So:

  • message:received, message:preprocessed, etc. = workspace/internal hooks
  • before_dispatch, before_agent_reply, reply_dispatch = Plugin SDK hooks via api.on(...)

So if your router is currently a workspace hook, there is no supported claim/suppress path there today. That is exactly why { handled: true } never bites. If you want the router to truly own the message, it needs to move into a plugin.

Docs:

A tiny plugin router would look roughly like this:

#
export default {
  id: "telegram-router",
  register(api) {
    api.on("before_dispatch", async (event, ctx) => {
      if (ctx.channelId !== "telegram") return;

      const text = (event.body ?? event.content ?? "").trim();
      const conversationId = ctx.conversationId; // often "telegram:-100123..."
      if (!text || !conversationId) return;

      const chatId = conversationId.replace(/^telegram:/, "");

      // 1) slash commands
      if (text.startsWith("/pstatus")) {
        const reply = await handlePstatus(text);
#
        await sendTelegram(chatId, reply);
        return { handled: true };
      }

      // 2) natural language project status
      if (looksLikeProjectStatusQuery(text)) {
        const reply = await handleProjectStatusQuery(text);
        await sendTelegram(chatId, reply);
        return { handled: true };
      }

      // 3) inbox checks
      if (looksLikeInboxCheck(text)) {
        const reply = await handleInboxCheck(text);
#
        // either send it yourself...
        await sendTelegram(chatId, reply);
        return { handled: true };

        // ...or let OpenClaw send it:
        // return { handled: true, text: reply };
      }

      return;
    });
  },
};

A decent routing shape is:

#
function looksLikeProjectStatusQuery(text: string): boolean {
  return /\b(project status|status of project|where are we on)\b/i.test(text);
}

function looksLikeInboxCheck(text: string): boolean {
  return /\b(check (my )?inbox|inbox status|any new mail)\b/i.test(text);
}

My recommendation: keep it deterministic.

  1. explicit slash commands first
  2. specific NL patterns second
  3. fall through to the normal agent for everything else

If you want HOOK.md workspace hooks to support claim semantics too, that feels like a feature request rather than a usage bug, so that would be a GitHub issue: https://github.com/openclaw/openclaw/issues

#

If you want, I can sketch the smallest possible plugin scaffold next, just enough to replace your current workspace hook router.

ember wyvern
# silk dove ```ts function looksLikeProjectStatusQuery(text: string): boolean { return /\b...

Yes please β€” full plugin scaffold. I'll need it to import from my existing workspace lib files (lib/pending.mjs, lib/anthropic.mjs, lib/telegram.mjs, projects.mjs, etc.) and from other workspace hooks (project-bcc/handler.js for poll(), status/handler.js for formatProject/fuzzyMatch, project/handler.js for handleConfirm/handleDismiss). Can a plugin import from workspace paths directly, or does that logic need to move into the plugin itself? Also: where does the plugin file live, how does it get registered in openclaw.json, and is there anything different about how environment variables are accessed?

silk doveBOT
#

Yep. Short version:

  • Use a plugin hook, not message:received
  • before_dispatch is the right place to fully own the message
  • Workspace imports can work for local dev, but I would treat them as a temporary bridge, not the final shape

1. Where the plugin lives

Simplest local layout:

<workspace>/.openclaw/extensions/router-plugin/
  openclaw.plugin.json
  index.ts
  workspace-bridge.ts   // optional, for ugly relative imports
#

OpenClaw auto-discovers workspace plugins from:

  • <workspace>/.openclaw/extensions/*/index.ts

Docs: https://docs.openclaw.ai/tools/plugin


2. Minimal scaffold

openclaw.plugin.json

{
  "id": "router-plugin",
  "name": "Router Plugin",
#
  "description": "Owns command-like inbound messages before the agent runs",
  "configSchema": {
    "type": "object",
    "additionalProperties": true
  }
}

index.ts

import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";

// local-only bridge to existing workspace files
import {
#
  poll,
  formatProject,
  fuzzyMatch,
  handleConfirm,
  handleDismiss,
  sendTelegram,
  getPendingState,
} from "./workspace-bridge.ts";

export default definePluginEntry({
  id: "router-plugin",
  name: "Router Plugin",
  description: "Owns routed inbound messages before normal agent dispatch",

  register(api) {
#
    api.on("before_dispatch", async (event, ctx) => {
      const text = (event.body || event.content || "").trim();

      if (text.startsWith("/inbox")) {
        const result = await poll();
        await sendTelegram(result);
        return { handled: true };
      }

      if (text.startsWith("/project ")) {
        const query = text.slice("/project ".length).trim();
        const match = fuzzyMatch(query);
        if (!match) {
          await sendTelegram("No matching project.");
          return { handled: true };
#
        }

        await sendTelegram(formatProject(match));
        return { handled: true };
      }

      if (text.startsWith("/confirm ")) {
        await handleConfirm(text);
        return { handled: true };
      }

      if (text.startsWith("/dismiss ")) {
        await handleDismiss(text);
        return { handled: true };
      }
#

      // let normal agent flow continue
      return;
    });
  },
});

3. Importing existing workspace files

Can it import them directly?
Technically yes, for a local workspace plugin.

Example bridge:

#

workspace-bridge.ts

export { poll } from "../../../project-bcc/handler.js";
export { formatProject, fuzzyMatch } from "../../../status/handler.js";
export { handleConfirm, handleDismiss } from "../../../project/handler.js";
export { default as sendTelegram } from "../../../lib/telegram.mjs";
export { default as getPendingState } from "../../../lib/pending.mjs";

But I’d strongly recommend this as phase 1 only.

Why:

  • it hard-codes your workspace layout
  • it will likely break if you later plugins install / package / move it
  • api.resolvePath() only helps inside the plugin root, not for arbitrary workspace paths
#

So the practical recommendation is:

  • local prototype: direct relative imports are fine
  • durable version: move shared logic into the plugin folder, or a small shared package/module both the plugin and hooks import

4. How to register it in config

If it lives in the workspace extensions folder, you usually just need to enable it:

{
  plugins: {
    entries: {
      "router-plugin": {
#
        enabled: true,
        config: {
          telegramChatId: "..."
        }
      }
    }
  }
}

If it lives somewhere else, also add a load path:

{
  plugins: {
#

5. Environment variables

Nothing special there.

A plugin runs in the same gateway Node process, so:

process.env.MY_VAR

still works.

What I’d prefer though:

  • env vars for secrets/shared external creds
  • api.pluginConfig for plugin-specific settings
  • api.logger instead of console.log
#

The bigger gotcha is usually paths, not env. Avoid relying on process.cwd().


6. Recommendation

For your exact case, I’d do this in two passes:

  1. Move the router into before_dispatch now
  2. Keep a thin workspace-bridge.ts to your existing libs
  3. Once behavior is stable, pull the reused logic into plugin-local modules

That gets you out of the duplicate-reply race immediately without forcing a big refactor first.

If you want, I can sketch the next step too: a slightly more complete scaffold with:

  • typed command matching
  • plugin config via api.pluginConfig
#
  • Telegram send helper
  • confirm/dismiss routing
  • a small router.ts split so index.ts stays tiny
ember wyvern
silk doveBOT
#

Yep, for plugins under .openclaw/extensions/ you can write plain index.ts. OpenClaw discovers .ts files there and loads native plugins in-process via jiti, so for local plugins you do not need a build step first.

So:

  • No build step required
  • No tsconfig.json required for runtime
  • tsconfig.json is optional if you want editor/type-checking niceties
  • .js / .mjs also works, but I’d only switch if you personally prefer it

Given your setup, I’d probably use:

  • index.ts for the plugin
  • keep importing your existing .mjs helpers for now

That mixed setup should be fine.

Relevant docs/source line up with this: