#another architecture question

1 messages Ā· Page 1 of 1 (latest)

dire osprey
#

another architecture question. We have Julie (a GPT-4o mini agent) monitoring 12 WhatsApp groups. The goal is to log every incoming group message to a file on disk in real time, then use scheduled crons (3x/day) to generate reports from that file.

Current setup: Julie is bound to the 12 groups via openclaw.json bindings. She responds NO_REPLY to everything. The problem is she only seems to "wake up" when a cron fires — she's not persistently receiving messages in real time.

Question: what's the right architecture for an agent that needs to process every group message as it arrives and write to a file — without responding in the group? Is there a persistent session mode, or should we use a different binding approach?

grim grailBOT
clever basinBOT
#

To help others find answers, you can mark your question as solved via Right click solution message -> Apps -> āœ… Mark Solution

merry tokenBOT
#

I checked the routing side, and there’s a cleaner pattern here than ā€œwake a chat agent on every message.ā€ Let me verify the hook path so I don’t hand-wave it.

#

Short version: there isn’t a separate ā€œpersistent session modeā€ for this.

The Gateway is the persistent listener. While it’s running, each inbound WhatsApp message should trigger processing immediately against that group’s session. Also, NO_REPLY only suppresses outbound delivery, it does not stop inbound routing.

For your use case, I’d recommend splitting ingestion from reporting:

  1. Ingestion: use an internal message:received hook to append each inbound WhatsApp group message to JSONL on disk
  2. Reporting: keep Julie as a cron-only agent that reads that file 3x/day and writes summaries somewhere else

That’s a better fit than ā€œwake an LLM on every message just to say NO_REPLYā€.

Docs:

#

If you do want to keep the current binding-based design, the likely issue is routing/gating, not persistence. Check these in order:

  • openclaw --version
  • openclaw status
  • openclaw gateway status
  • openclaw logs --follow

Then verify WhatsApp group config:

  • groupPolicy
  • groupAllowFrom
  • groups allowlist
  • requireMention

For ā€œprocess every group messageā€, you usually want something like:

#
{
  channels: {
    whatsapp: {
      groupPolicy: "allowlist",
      groups: {
        "1203...@g.us": { requireMention: false },
        "1204...@g.us": { requireMention: false }
      }
    }
  }
}

or wildcard if you truly want all groups:

#
groups: { "*": { requireMention: false } }

One important WhatsApp gotcha: if you’re using account-level groups, that map replaces the root groups map, it does not deep-merge.

So my recommendation is:

  • Best architecture: hook/plugin logs inbound messages, Julie only does cron summaries
  • Acceptable but heavier: bindings + requireMention: false + Julie writes file + returns NO_REPLY

If you paste Julie’s channels.whatsapp block plus the binding snippet, I can tell you pretty quickly which gate is blocking the real-time wakeups.

dire osprey
#

Quick clarification before we pick an option: in both cases (hook or requireMention: false + NO_REPLY), group participants would see nothing from the bot, correct? Julie stays completely invisible in the groups either way?

Also — is the message:received hook available in 2026.4.21, or is it coming in a future release?

merry tokenBOT
#

Let me check that version detail instead of guessing. That’s the kind of thing that’s annoying to get wrong.

#

Yep, with one important WhatsApp caveat.

If by ā€œinvisibleā€ you mean no chat messages posted into the group, then:

  • hook path: yes, can be fully silent
  • requireMention: false + NO_REPLY path: also yes, if Julie never emits anything except NO_REPLY

But on WhatsApp, OpenClaw runs on your linked account, not a separate bot user, so two other things can still make activity visible unless you disable them:

{
  channels: {
    whatsapp: {
      sendReadReceipts: false,
      reactionLevel: "off"
    }
#
  }
}

Why that matters:

  • sendReadReceipts is on by default
  • reactionLevel defaults to "minimal", which can allow reactions/ack behavior

So the safest ā€œtotally quietā€ setup is:

  • use a message:received hook for logging
  • set sendReadReceipts: false
  • set reactionLevel: "off"

That gives you silent ingestion without relying on the model to behave.

#

And on the version question: yes, message:received is already present before 2026.4.21. I verified it exists in a local 2026.4.9 install already, both in docs and runtime, so it is not a future-only feature.

Relevant docs:

So my recommendation stays:

  • Best: hook for ingestion, Julie cron-only for summaries
  • Works but less robust: requireMention: false + file write + NO_REPLY

If you want, I can sketch the exact openclaw.json shape for the quiet hook-based version next.

dire osprey
#

Perfect — going with the hook approach. Can you sketch the exact openclaw.json shape for the message:received hook that appends each inbound WhatsApp group message to a JSONL file on disk? We're on 2026.4.21, VPS Linux.

merry tokenBOT
#

I’m going to pull the exact hook config shape so I can give you something copy-pasteable, not ā€œroughly this.ā€

#

Yep. For 2026.4.21 on Linux, I’d set it up like this.

One important nuance first: message:received is in the accepted inbound pipeline, not a raw WhatsApp tap. So if you want every group message to hit the hook, you still want those 12 groups admitted and requireMention: false.

1) openclaw.json shape

This is JSON5, not strict JSON:

{
  channels: {
    whatsapp: {
      // Keep Julie quiet on-surface
      sendReadReceipts: false,
      reactionLevel: "off",
#
      // Admit only the 12 groups you want to log
      groupPolicy: "allowlist",
      groups: {
        "120363424282127706@g.us": { requireMention: false },
        "120363400000000001@g.us": { requireMention: false },
        "120363400000000002@g.us": { requireMention: false },
        // ... the rest of your 12 groups
      },

      // Optional, if you also restrict which senders may trigger in groups:
      // groupAllowFrom: ["+15551234567"]
    },
  },

  hooks: {
#
    internal: {
      entries: {
        "whatsapp-group-jsonl": {
          enabled: true,
          env: {
            LOG_PATH: "/var/lib/openclaw/logs/whatsapp-groups.jsonl",

            // comma-separated allowlist, same 12 group ids
            GROUP_IDS:
              "120363424282127706@g.us,120363400000000001@g.us,120363400000000002@g.us",

            // optional if you run multiple WhatsApp accounts
            // ACCOUNT_ID: "julie"
          },
        },
#
      },
    },
  },
}

If you’re using account-level WhatsApp config, put groups under channels.whatsapp.accounts.<id>.groups instead. WhatsApp account-level groups replaces root groups, it does not deep-merge.

2) Hook files

I’d put this in the managed hooks dir:

~/.openclaw/hooks/whatsapp-group-jsonl/
ā”œā”€ā”€ HOOK.md
#
└── handler.ts

~/.openclaw/hooks/whatsapp-group-jsonl/HOOK.md

---
name: whatsapp-group-jsonl
description: "Append inbound WhatsApp group messages to a JSONL log file"
metadata:
  openclaw:
    emoji: "🧾"
    events: ["message:received"]
---
#
# WhatsApp Group JSONL Logger

Appends inbound WhatsApp group messages to a JSONL file on disk.

~/.openclaw/hooks/whatsapp-group-jsonl/handler.ts

import { appendFile, mkdir } from "node:fs/promises";
import { dirname } from "node:path";

function asString(value: unknown): string | undefined {
  return typeof value === "string" && value.trim() ? value : undefined;
}
#
function asRecord(value: unknown): Record<string, unknown> {
  return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
}

const LOG_PATH =
  process.env.LOG_PATH?.trim() || "/var/lib/openclaw/logs/whatsapp-groups.jsonl";

const GROUP_IDS = new Set(
  (process.env.GROUP_IDS || "")
    .split(",")
    .map((s) => s.trim())
    .filter(Boolean),
);

const ACCOUNT_ID = process.env.ACCOUNT_ID?.trim();
#

export default async function handler(event: any) {
  if (event?.type !== "message" || event?.action !== "received") {
    return;
  }

  const ctx = event?.context ?? {};
  const meta = asRecord(ctx.metadata);

  if (ctx.channelId !== "whatsapp") {
    return;
  }

  if (ACCOUNT_ID && ctx.accountId !== ACCOUNT_ID) {
    return;
#
  }

  const conversationId = asString(ctx.conversationId);
  if (!conversationId || !conversationId.endsWith("@g.us")) {
    return;
  }

  if (GROUP_IDS.size > 0 && !GROUP_IDS.has(conversationId)) {
    return;
  }

  const row = {
    ts:
      typeof ctx.timestamp === "number"
        ? new Date(ctx.timestamp).toISOString()
#
        : new Date().toISOString(),

    channelId: ctx.channelId,
    accountId: asString(ctx.accountId) ?? null,
    conversationId,
    messageId: asString(ctx.messageId) ?? null,

    from: asString(ctx.from) ?? null,
    text: asString(ctx.content) ?? "",

    senderId: asString(meta.senderId) ?? null,
    senderName: asString(meta.senderName) ?? null,
    senderUsername: asString(meta.senderUsername) ?? null,
    senderE164: asString(meta.senderE164) ?? null,
#
    provider: asString(meta.provider) ?? null,
    surface: asString(meta.surface) ?? null,
    to: asString(meta.to) ?? null,
  };

  await mkdir(dirname(LOG_PATH), { recursive: true });
  await appendFile(LOG_PATH, JSON.stringify(row) + "\n", "utf8");

  // Intentionally no event.messages.push(...) so this stays silent.
}

3) Enable + reload

#
openclaw hooks list
openclaw hooks enable whatsapp-group-jsonl
openclaw gateway restart

Then verify with:

openclaw hooks list
openclaw logs --follow

4) What this gives you

  • logs each accepted WhatsApp group message to one JSONL file
  • no group replies
dire osprey
#

Hook is showing ready in openclaw hooks list and config is correct (requireMention: false on 12 groups, hook enabled: true in openclaw.json), but groups are very active and nothing is being written to the JSONL file.

Is there a way to verify the hook is actually firing? And does an internal hook in openclaw.json need anything beyond enabled: true + the handler files in ~/.openclaw/hooks/whatsapp-group-jsonl/?

merry tokenBOT
#

Yeah, there are a few gotchas here.

Short answer:

  • No, an internal hook does not need much more than:
    • handler files in ~/.openclaw/hooks/<name>/
    • hooks.internal.entries.<name>.enabled = true
    • gateway restart
  • But ready only means discovered + eligible, not ā€œI definitely fired on a real eventā€.

The fastest way to verify it is firing is to temporarily remove all filters and write the raw event to a known-writable file.

First checks

Run these first:

#
openclaw hooks info whatsapp-group-jsonl
openclaw gateway restart
openclaw logs --follow

What I’d look for:

  1. hooks info should show the actual path to your HOOK.md / handler.ts
  2. HOOK.md should list events: ["message:received"]
  3. after restart, logs should show hooks loaded
  4. make sure the gateway is running as the same user/home that contains ~/.openclaw/hooks/...

Most likely failure modes

In your case, the top suspects are:

#
  1. gateway wasn’t restarted after enabling/changing the hook
  2. LOG_PATH is not writable
  3. your handler filter is too strict
  4. gateway is running under a different user than the shell where you ran openclaw hooks list

Also, use an absolute path for LOG_PATH. Do not use ~ inside env, Node won’t expand it for you.

Best probe handler

Temporarily replace your handler with this:

import { appendFile, mkdir } from "node:fs/promises";
import { dirname } from "node:path";

const DEBUG_PATH = "/tmp/whatsapp-hook-probe.jsonl";
#

export default async function handler(event: any) {
  await mkdir(dirname(DEBUG_PATH), { recursive: true });

  await appendFile(
    DEBUG_PATH,
    JSON.stringify({
      ts: new Date().toISOString(),
      type: event?.type,
      action: event?.action,
      context: event?.context,
    }) + "\n",
    "utf8",
  );
#
  console.log(
    "[whatsapp-group-jsonl] fired",
    event?.type,
    event?.action,
    event?.context?.channelId,
    event?.context?.conversationId,
  );
}

Then:

  1. restart gateway
  2. send one test message into one of the 12 groups
  3. check:
#
tail -f /tmp/whatsapp-hook-probe.jsonl

If that file gets rows, the hook is firing, and the problem is your filter or output path.

Why I suspect the filter

A common one is assuming:

ctx.channelId === "whatsapp"
ctx.conversationId.endsWith("@g.us")

One of those may not match exactly as you expect in your runtime, so the raw probe will tell you the real shape immediately.

#

If the probe file stays empty

Then it’s usually one of these:

  • wrong hook path / wrong user
  • hook not actually loaded by the running gateway
  • no gateway restart
  • the inbound messages are not reaching accepted dispatch at all

If you want, paste the output of:

openclaw hooks info whatsapp-group-jsonl

and one redacted line from /tmp/whatsapp-hook-probe.jsonl, and I can help tighten the final handler.

dire osprey
#

After restart, gateway goes silent on all groups — zero group messages received for 15+ min despite very active groups. Direct messages work fine. messagesHandled stays at 3 (direct only). Hook probe confirms it fires on directs but nothing from groups. This seems to be the 30-min watchdog disconnecting groups after restart. Is there a way to force group reconnection without a full restart? Or a config to keep groups alive longer?

merry tokenBOT
#

I’m going to check the WhatsApp channel docs/source for any watchdog or group-specific reconnect setting before I send you down the wrong path.

#

I don’t think this is a ā€œ30 min group watchdogā€ thing.

I checked the docs/source, and I don’t see a WhatsApp group-only timeout/keepalive knob. WhatsApp uses the global web transport settings:

  • web.heartbeatSeconds
  • web.reconnect.*

Docs: https://docs.openclaw.ai/gateway/configuration-reference#whatsapp

Also, if DMs are still flowing, the WhatsApp Web transport is not actually dead. That points much more to group routing/auth than reconnect.

Most likely culprit

For WhatsApp, these are separate:

  1. Which groups are admitted → groups
  2. Which senders inside those groups are allowed → groupPolicy + groupAllowFrom
#

And the docs are explicit:

  • if groupPolicy: "allowlist", sender must match groupAllowFrom or *
  • if groupAllowFrom is unset, it falls back to allowFrom

So this setup:

groupPolicy: "allowlist",
groups: {
  "1203...@g.us": { requireMention: false }
}

does not mean ā€œeveryone in that group can triggerā€.
It means ā€œthat group is eligible, but senders are still filteredā€.

#

If you want every member in those 12 groups to be logged, the safe shape is:

{
  channels: {
    whatsapp: {
      groupPolicy: "allowlist",
      groupAllowFrom: ["*"],
      groups: {
        "120363424282127706@g.us": { requireMention: false },
        "120363400000000001@g.us": { requireMention: false },
        // ...
      },
    },
  },
}
#

That keeps the group list locked to those 12, while allowing any sender inside those 12.

Important if you use multi-account

If Julie is a specific WhatsApp account, put groupPolicy, groupAllowFrom, and groups under the same account scope:

channels: {
  whatsapp: {
    accounts: {
      julie: {
        groupPolicy: "allowlist",
        groupAllowFrom: ["*"],
        groups: {
          "1203...@g.us": { requireMention: false },
        },
#
      },
    },
  },
}

Root and account-level maps can trip people up.

About force-reconnect

I don’t see a first-class ā€œforce group reconnectā€ or ā€œkeep groups alive longerā€ setting specifically for WhatsApp groups.

The reconnect knobs I can verify are only the channel-wide web ones:

#
web: {
  heartbeatSeconds: 60,
  reconnect: {
    initialMs: 2000,
    maxMs: 120000,
    factor: 1.4,
    jitter: 0.2,
    maxAttempts: 0
  }
}

Those help when the whole WhatsApp web channel is unhealthy, not when DMs work but groups are filtered.

What I’d do next

#

After one test message in a target group, check logs for these exact drop patterns:

  • groupPolicy=allowlist (not allowlisted)
  • Blocked ... group message (no group allowlist)
  • route_not_allowlisted
  • Group message stored for context (no mention detected)

Given what you described, I’d bet on missing/too-strict groupAllowFrom rather than reconnect.

If you paste the redacted channels.whatsapp block, I can sanity-check the exact scope in one pass.

dire osprey
#

: Set requireMention: false on 12 groups so the message:received hook could receive messages. It worked — hook started capturing. But the agent also started replying in the groups. We never want the agent to reply in groups, only the hook to log silently.

How do we receive all group messages via hook WITHOUT the agent ever replying? Is there a way to have requireMention: false for hook ingestion but block agent replies in groups entirely?

merry tokenBOT
#

I’m glad you caught that. Let me check the actual reply-suppression knobs instead of improvising, because there may be a cleaner config-only answer than a custom plugin.

#

Yep, there is a clean config-only way to do the surface part of this.

What to use

Use session.sendPolicy to deny outbound delivery for WhatsApp group chats.

That lets:

  • inbound group messages still be accepted
  • your message:received hook still fire
  • group sessions still exist
  • all outbound replies be suppressed in those groups
{
  session: {
#
    sendPolicy: {
      default: "allow",
      rules: [
        {
          action: "deny",
          match: { channel: "whatsapp", chatType: "group" },
        },
      ],
    },
  },

  channels: {
    whatsapp: {
      groupPolicy: "allowlist",
      groupAllowFrom: ["*"],
#

Important nuance

This blocks delivery, not the LLM turn.

So if your goal is:

A) ā€œNever post in groupsā€

session.sendPolicy is enough. This is the simplest answer.

B) ā€œNever even run the model for group messages, only run the hookā€

That is not something an internal HOOK.md hook can do by itself.

For that, you need a plugin hook using before_agent_reply to claim the turn and return silence before the LLM runs.

So the short truth is:

  • hook ingestion + no visible group replies → yes, use session.sendPolicy
#
  • hook ingestion + no LLM execution at all → needs a plugin, not just openclaw.json

Given your use case, I’d start with session.sendPolicy. It’s the smallest change and should solve the ā€œJulie replied in the groupsā€ problem immediately.

If you want, I can sketch the tiny before_agent_reply plugin too, but I’d try sendPolicy first.

dire osprey
#

"Hi Krill! Does sendPolicy: deny for groups apply to secondary agents (like Julie) or only to the main agent? Julie is responding in WhatsApp groups even with the deny rule set in the main openclaw.json."

merry tokenBOT
#

Let me verify the precedence here. I’d rather be precise than say ā€œshouldā€ and waste your time.

#

Yes, global session.sendPolicy is supposed to apply to Julie too, not just main.

I checked the implementation, and it strips the agent:<agentId>: prefix before matching normal rules, so a rule like this should catch secondary-agent WhatsApp group sessions too:

session: {
  sendPolicy: {
    default: "allow",
    rules: [
      { action: "deny", match: { channel: "whatsapp", chatType: "group" } },
    ],
  },
}

Docs: https://docs.openclaw.ai/gateway/configuration-reference#session

#

Why Julie can still reply anyway

Two likely reasons:

1) A per-session override is winning

entry.sendPolicy on the session row beats global config.

So if Julie’s existing WhatsApp group session already has sendPolicy: "allow" (or no deny), that can override the global rule.

2) The rule is not matching Julie’s session metadata

If channel/chatType metadata is stale or weird on that session, the broad rule can miss.

Easiest way to make it unambiguous

Use a prefix match on the session key instead.

For all agents on WhatsApp groups:

#
{
  session: {
    sendPolicy: {
      default: "allow",
      rules: [
        { action: "deny", match: { keyPrefix: "whatsapp:group:" } },
      ],
    },
  },
}

For Julie only:

#
{
  session: {
    sendPolicy: {
      default: "allow",
      rules: [
        { action: "deny", match: { rawKeyPrefix: "agent:julie:whatsapp:group:" } },
      ],
    },
  },
}

That avoids relying on derived channel/chatType.

If Julie is still replying after that

#

Then I’d strongly suspect a stale session entry override. In that case, test with a brand-new Julie group session or clear/reset the old Julie WhatsApp group session rows so they re-resolve policy from config.

So the short answer is:

  • No, this is not main-agent-only
  • Yes, it should apply to Julie
  • Use keyPrefix / rawKeyPrefix if you want to make it bulletproof

If you want, paste a redacted Julie session key or her agent id exactly as configured, and I’ll give you the exact rawKeyPrefix rule.

dire osprey
#

"Hi Krill, . Julie (secondary agent) is responding in WhatsApp groups even after:
1. Adding sendPolicy: deny with rawKeyPrefix: "agent:julie:whatsapp:group:" to openclaw.json
2. Clearing all Julie's group sessions from sessions.json
3. Restarting the gateway

She keeps posting messages like "The memory has been successfully updated" directly in the groups, and our team members are seeing it. This is causing embarrassment.

Julie's SOUL.md says L1 = NO_REPLY always. But she's still delivering responses.

Is there a way to hard-block delivery for a secondary agent, regardless of what the agent returns? Something at the gateway level that intercepts before delivery?"


merry tokenBOT
#

I’m going to look up that exact message text. If it’s coming from a hook or tool confirmation path, that explains why the normal reply policy didn’t catch it.

#

Oof, that’s rough. I’m sorry, I can see why that’s embarrassing.

At this point, I’d treat it as bug territory:

  • sendPolicy: "deny" is supposed to be the config-level hard gate
  • if rawKeyPrefix: "agent:julie:whatsapp:group:" still leaked after clearing sessions + restart, that is not expected

So for an emergency hard stop, I would stop relying on config-only and add a plugin-level outbound kill switch.

Short answer

There is no stronger JSON-only gateway switch I can verify beyond session.sendPolicy.

If that failed, the next real hard block is:

  • message_sending plugin hook with { cancel: true }
    this intercepts right before delivery
#
      if (ctx.channelId !== "whatsapp") return;
      if (!ctx.conversationId?.endsWith("@g.us")) return;

      // safest emergency mode: block all WhatsApp group outbound
      return { cancel: true };
    });
  },
};

That should catch even weird confirmations like "The memory has been successfully updated".

Layer 2, stop Julie before the LLM runs

Targets Julie specifically.

#
export default {
  id: "quiet-julie-groups",
  register(api) {
    api.on("before_agent_reply", async (_event, ctx) => {
      if (ctx.agentId !== "julie") return;
      if (ctx.channelId !== "whatsapp") return;
      if (!ctx.sessionKey?.includes(":group:")) return;

      return {
        handled: true,
        reply: { text: "NO_REPLY" },
        reason: "Julie is log-only in WhatsApp groups",
      };
    });
  },
#
};

My recommendation

Use both:

  • before_agent_reply = Julie never thinks/responds in WA groups
  • message_sending = absolute last-line brake if anything still tries to send

That gives you a real defense-in-depth setup.

One more thing

If sendPolicy truly leaked a secondary-agent reply after the steps you listed, I’d file a bug here:

#

https://github.com/openclaw/openclaw/issues

I can’t open it for you, but I’d include:

  • OpenClaw version
  • redacted session.sendPolicy
  • one example Julie session key
  • proof that outbound still happened after restart

If you want, I can sketch the full minimal plugin package layout next so you can drop it into the VPS quickly.

dire osprey
#

"Thanks! Yes, please share the full plugin layout.

One important requirement: the block must be conditional, not permanent. Julie has levels (L1 = silent, L2 = can reply in groups). When we promote her to L2, we need to be able to turn the block off without rewriting the plugin.

Ideally the plugin reads a config value or env variable (e.g. JULIE_LEVEL=L1) so we can flip it without touching code. Is that possible with the plugin SDK?"

merry tokenBOT
#

I’m going to pull the plugin layout and config bits so I can give you a copy-pasteable example, not vague SDK vibes.

#

Yes, that’s possible, and I’d do it with plugin config first, env fallback second.

Also, one important nuance I found: message_sending does not expose agentId in its typed context, so for the hard last-line brake you should scope it by WhatsApp account and/or the exact 12 group IDs. For Julie-specific logic, use before_agent_reply, which does get agentId.

So the safest pattern is:

  • before_agent_reply: if level === "L1" and agentId === "julie", short-circuit to NO_REPLY
  • message_sending: if level === "L1" and outbound target is one of Julie’s 12 WhatsApp groups, cancel: true

That gives you both:

  • Julie-specific silent mode
  • hard outbound kill switch before delivery

Docs:

#

Minimal plugin layout

julie-group-guard/
ā”œā”€ā”€ package.json
ā”œā”€ā”€ openclaw.plugin.json
└── index.ts

package.json

{
  "name": "@yourorg/julie-group-guard",
  "version": "0.1.0",
  "type": "module",
#
  "openclaw": {
    "extensions": ["./index.ts"],
    "compat": {
      "pluginApi": ">=2026.4.21",
      "minGatewayVersion": "2026.4.21"
    }
  }
}

openclaw.plugin.json

{
  "id": "julie-group-guard",
#
  "name": "Julie Group Guard",
  "description": "Conditional silent-mode guard for Julie in WhatsApp groups",
  "configSchema": {
    "type": "object",
    "additionalProperties": false,
    "properties": {
      "enabled": { "type": "boolean" },
      "level": { "type": "string", "enum": ["L1", "L2"] },
      "agentId": { "type": "string" },
      "channelId": { "type": "string" },
      "accountId": { "type": "string" },
      "groupIds": {
        "type": "array",
        "items": { "type": "string" }
      }
#
    }
  }
}

index.ts

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

function asString(value: unknown): string | undefined {
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
}

function asStringArray(value: unknown): string[] {
#
  return Array.isArray(value)
    ? value.filter((v): v is string => typeof v === "string" && v.trim().length > 0).map((v) => v.trim())
    : [];
}

function normalizeLevel(value: unknown): "L1" | "L2" {
  const raw = typeof value === "string" ? value.trim().toUpperCase() : "";
  return raw === "L2" ? "L2" : "L1";
}

export default definePluginEntry({
  id: "julie-group-guard",
  name: "Julie Group Guard",
  description: "Conditional silent-mode guard for Julie in WhatsApp groups",
#
  register(api) {
    const readConfig = () => {
      const cfg = (api.pluginConfig ?? {}) as Record<string, unknown>;

      return {
        enabled: cfg.enabled !== false,
        level: normalizeLevel(cfg.level ?? process.env.JULIE_LEVEL),
        agentId: asString(cfg.agentId) ?? "julie",
        channelId: asString(cfg.channelId) ?? "whatsapp",
        accountId: asString(cfg.accountId),
        groupIds: asStringArray(cfg.groupIds),
      };
    };

    const isManagedGroup = (conversationId?: string) => {
#
      if (!conversationId) return false;
      const cfg = readConfig();
      if (cfg.groupIds.length === 0) return conversationId.endsWith("@g.us");
      return cfg.groupIds.includes(conversationId);
    };

    // Julie-specific: stop the LLM turn entirely in L1
    api.on("before_agent_reply", async (_event, ctx) => {
      const cfg = readConfig();

      if (!cfg.enabled || cfg.level !== "L1") return;
      if (ctx.agentId !== cfg.agentId) return;
      if (ctx.channelId !== cfg.channelId) return;
      if (!ctx.sessionKey?.includes(":group:")) return;
#
      api.logger.info("Suppressing Julie group reply in L1", {
        agentId: ctx.agentId,
        sessionKey: ctx.sessionKey,
      });

      return {
        handled: true,
        reply: { text: "NO_REPLY" },
        reason: "Julie L1 silent mode",
      };
    });

    // Last-line brake: cancel outbound delivery to managed WhatsApp groups
    api.on("message_sending", async (_event, ctx) => {
      const cfg = readConfig();
#

      if (!cfg.enabled || cfg.level !== "L1") return;
      if (ctx.channelId !== cfg.channelId) return;
      if (cfg.accountId && ctx.accountId !== cfg.accountId) return;
      if (!isManagedGroup(ctx.conversationId)) return;

      api.logger.warn("Cancelling outbound WhatsApp group delivery in L1", {
        conversationId: ctx.conversationId,
        accountId: ctx.accountId,
      });

      return { cancel: true };
    });
  },
});
#

Config in openclaw.json

{
  plugins: {
    entries: {
      "julie-group-guard": {
        enabled: true,
        config: {
          enabled: true,
          level: "L1", // flip to "L2" to allow replies again
          agentId: "julie",
          channelId: "whatsapp",
          accountId: "julie", // optional, but recommended if Julie has a dedicated WA account
          groupIds: [
            "120363424282127706@g.us",
#
            "120363400000000001@g.us",
            "120363400000000002@g.us"
          ]
        }
      }
    }
  }
}

Install

openclaw plugins install /path/to/julie-group-guard
openclaw gateway restart
#

Flip L1 ↔ L2 without code changes

Two good options:

Option A, preferred: plugin config

Change:

plugins.entries["julie-group-guard"].config.level = "L2"

then restart gateway.

Option B: env var fallback

Set:

#
JULIE_LEVEL=L2

and restart gateway.

If the gateway runs as a service, make sure that env var is actually available to the service process, not just your shell.

Recommendation

I’d use:

  • plugin config as the main switch
  • env fallback only if you really want ops-level flipping outside config

That keeps it auditable and less spooky.

#

If you want, I can also sketch a tiny logging line to /tmp/julie-group-guard.log so you can prove the block is firing before you trust it in production.

dire osprey
#

"Hi Krill! The before_agent_reply hook is working — Julie is silent in groups. But she's also not writing to her daily accumulator file (a tool call she should make before replying NO_REPLY).

Does before_agent_reply intercept before or after tool calls? Is there a way to let the agent run its tool calls (write to file) but still block the final reply delivery to the group?"

--

#

"Hi Krill! I checked the docs and found that before_agent_reply fires before the LLM call — so Julie never runs at all, which means she's not writing to her daily accumulator file either.

Julie needs to run (use tools, write files) but NOT deliver any reply to the WhatsApp group.

My plan: remove before_agent_reply from the plugin and keep only message_sending with cancel: true. That way Julie processes and writes files, but the outbound delivery is blocked.

Is that the right approach? Any risk with this setup?"

merry tokenBOT
#

Yep, that’s the right approach for this case.

If Julie must still:

  • wake on every group message,
  • use tools,
  • write files,

but never post into the WhatsApp group, then:

  • remove before_agent_reply
  • keep message_sending with cancel: true

That gives you process everything, deliver nothing.

Why

#

before_agent_reply stops the run before the LLM/tools happen.

message_sending fires in the outbound delivery path, so Julie can still do work, but the final WhatsApp group send gets canceled right before delivery.

Recommended setup

Use message_sending as the real brake, scoped narrowly to:

  • channelId === "whatsapp"
  • Julie’s accountId if she has her own WA account
  • only the 12 target groupIds
  • only when level === "L1"

I’d also keep these as extra guardrails:

#
channels: {
  whatsapp: {
    sendReadReceipts: false,
    reactionLevel: "off"
  }
}

Main risk with this setup

The risk is not delivery, it’s side effects:

  1. Julie still runs
    • model cost still happens
    • tools still run
    • file writes still happen
#
  • any other side effects still happen
  1. Transcript/session still records the assistant turn

    • even if delivery is canceled, Julie may still ā€œthinkā€ she replied
    • that can add noise to context over time
  2. Scope carefully

    • don’t block all WhatsApp sends unless you mean to
    • match only Julie’s account + those 12 group IDs

So yes, your plan is correct. I’d describe it as:

  • L1 = run normally, but hard-cancel outbound group delivery
  • L2 = let delivery through again by flipping config/env and restarting gateway

Best practice

#

I’d keep both:

  • Julie prompt still prefers NO_REPLY
  • plugin message_sending is the hard safety net

That way:

  • normal case stays quiet
  • if Julie or a tool tries to say something anyway, it still gets blocked

Docs:

If you want, I can tighten the plugin example one more step so it matches Julie account + exact 12 group IDs only, which is the safest production version.

dire osprey
#

"Hi Krill, the message_sending hook is still not blocking Julie's replies even after adding plugins.allow: ["julie-group-guard"]. She replied in group 120363252033540927@g.us at 17:32 BRT, well after the restart.

Two issues confirmed:

1. message_sending with cancel: true is not firing — no log entries from the plugin at all

2. Session compaction is destroying Julie's identity — after compaction, the summary becomes "Assist with coding issues" and Julie replies as a generic assistant instead of following her SOUL.md

Is there a way to verify the message_sending hook is actually being registered? And is there a compaction config to inject a custom summary or preserve agent identity across compactions?"


#

"Also found that message_sending hook has zero log entries — it's never being called. Is there a way to verify the hook is actually registered at runtime? Could plugins.allow or the TypeScript compilation be the issue?

One more: session compaction is replacing Julie's identity summary with "Assist with coding issues". Is there a config to preserve agent identity across compactions, or inject a fixed prefix into the compaction summary?"


merry tokenBOT
#

Yeah, I think you’ve got two separate bugs/smells here.

1) Verify the hook is actually loaded

First, please paste:

openclaw --version
openclaw plugins list --enabled
openclaw plugins inspect julie-group-guard
openclaw plugins inspect julie-group-guard --json
openclaw plugins doctor
OPENCLAW_PLUGIN_LOAD_DEBUG=1 openclaw plugins doctor

What I’d look for:

#
  • plugin status is loaded
  • shape is probably hook-only
  • hook list includes message_sending

Important nuance: plugins.allow only makes a plugin eligible to load. It does not enable it.

So also make sure you have one of these:

plugins: {
  entries: {
    "julie-group-guard": {
      enabled: true
    }
  }
}
#

or:

openclaw plugins enable julie-group-guard

Docs:

If inspect shows the plugin is loaded and message_sending is registered, but Julie still replies and your hook logger never fires, I’d call that bug territory. message_sending is supposed to be the outbound guard.

2) Compaction and Julie’s identity

This part is more subtle, and I think you found the real gotcha.

There is not a plain config knob for ā€œinject this custom auto-compaction summary textā€.

#

The built-in supported knobs are things like:

  • agents.defaults.compaction.model
  • agents.defaults.compaction.identifierPolicy
  • agents.defaults.compaction.postCompactionSections
  • agents.defaults.contextLimits.postCompactionMaxChars

If you want to fully own the summary, that is custom compaction provider plugin territory.

The important bit

Post-compaction refresh currently re-injects sections from AGENTS.md, not SOUL.md.

So if Julie’s identity mostly lives in SOUL.md, compaction can absolutely make her feel generic unless AGENTS.md tells her how to recover.

I’d do this:

#
agents: {
  defaults: {
    contextInjection: "always",
    contextLimits: {
      postCompactionMaxChars: 4000
    },
    compaction: {
      postCompactionSections: ["Julie Identity", "Session Startup", "Red Lines"]
    }
  }
}

Then in Julie’s AGENTS.md, add H2/H3 sections like:

  • ## Julie Identity
#
  • ## Session Startup
  • ## Red Lines

And put the critical stuff there, for example:

  • you are Julie
  • monitor these WhatsApp groups
  • log every inbound message
  • never deliver replies into the group
  • re-read SOUL.md, IDENTITY.md, USER.md before responding after compaction

Also double-check you did not set:

agents.defaults.contextInjection: "continuation-skip"
#

For an identity-heavy agent like Julie, I’d keep it at the default, "always".

Docs:

My honest read

Most likely:

  1. the plugin is not actually enabled/loaded, even though it’s allowlisted
  2. Julie’s identity is too SOUL-heavy, while post-compaction recovery is AGENTS-driven

If you paste the plugins inspect output, I can help you pinpoint which of those is biting you.

#

Let me check two things quickly: whether native plugins are expected to load .ts directly, and whether there’s any real built-in knob for ā€œfixed compaction identity prefixā€ versus needing a custom provider.

#

Yep, there’s a clean way to verify both.

First, please paste this set, starting with version:

openclaw --version
openclaw status --all
openclaw plugins list --enabled
openclaw plugins inspect julie-group-guard
openclaw plugins inspect julie-group-guard --json
openclaw plugins doctor
OPENCLAW_PLUGIN_LOAD_DEBUG=1 openclaw plugins doctor

Hook registration: what to verify

openclaw plugins inspect julie-group-guard should tell you:

#
  • whether the plugin is actually loaded
  • whether it is hook-only
  • whether message_sending appears in its registered hooks

If inspect does not show message_sending, then the hook never registered.

Two common gotchas

  1. plugins.allow is not enough
    It only makes the plugin eligible to load.

    You still need the plugin enabled, either with:

plugins: {
  entries: {
#
    "julie-group-guard": {
      enabled: true
    }
  }
}

or:

openclaw plugins enable julie-group-guard
  1. TypeScript itself is probably not the issue
    Native plugins can load .ts entrypoints directly. OpenClaw loads native plugins in-process via jiti, so index.ts is a normal supported shape.
#

Short answer: there is no built-in config for ā€œprepend this fixed identity prefix to every compaction summary.ā€

For full control of the summary, you need a custom compaction provider plugin via registerCompactionProvider().

What is built in:

  • agents.defaults.compaction.postCompactionSections
  • agents.defaults.contextLimits.postCompactionMaxChars
  • agents.defaults.compaction.model
  • agents.defaults.compaction.identifierPolicy

Important nuance

Post-compaction recovery re-injects AGENTS.md sections, not SOUL.md.

So if Julie’s personality/role lives mostly in SOUL.md, compaction can absolutely make her drift generic unless Julie’s AGENTS.md tells her how to recover.

#

I’d make Julie’s AGENTS.md contain headings like:

  • ## Julie Identity
  • ## Session Startup
  • ## Red Lines

And configure:

agents: {
  defaults: {
    contextInjection: "always",
    contextLimits: {
      postCompactionMaxChars: 4000
    },
    compaction: {
#
      postCompactionSections: ["Julie Identity", "Session Startup", "Red Lines"]
    }
  }
}

Then put the critical identity there, including something like:

  • you are Julie
  • you monitor these WhatsApp groups
  • you log every inbound message
  • you never deliver replies into the group
  • after compaction, re-read SOUL.md, IDENTITY.md, and USER.md

Docs:

dire osprey
#

openclaw plugins inspect julie-group-guard --json output:

{
  "plugin": {
    "id": "julie-group-guard",
    "enabled": true,
    "explicitlyEnabled": true,
    "activated": true,
    "activationSource": "explicit",
    "status": "loaded",
    "hookNames": [],
    "hookCount": 1,
    ...
  }
}

O ponto crĆ­tico: "hookNames": [] mas "hookCount": 1 — o hook existe mas nĆ£o estĆ” registrado no sistema.

#
Plugin: julie-group-guard
status: loaded
enabled: true
hookNames: []   ← vazio (bug)
hookCount: 1    ← hook existe no código
activationReason: enabled in config
merry tokenBOT
#

Nice catch. I’m checking the inspect JSON semantics before I say ā€œthat means Xā€, because hookCount: 1 plus empty hookNames smells like a reporting quirk, not a clean ā€œnot registeredā€ signal.

#

Ah, that specific output is actually very revealing, and I think the interpretation is a bit off.

What hookNames: [] + hookCount: 1 means

For plugins, OpenClaw tracks two different hook buckets:

  • hookNames = custom/legacy hooks registered via registerHook(...)
  • hookCount / typedHooks = typed lifecycle hooks registered via api.on(...)

message_sending is a typed lifecycle hook.

So if your plugin uses:

api.on("message_sending", handler)
#

then this is the expected shape:

"hookNames": [],
"hookCount": 1

That does not mean ā€œthe hook exists but is not registeredā€.
It usually means one typed hook is registered.

The real check

Please look at the top-level typedHooks section from:

openclaw plugins inspect julie-group-guard
#
openclaw plugins inspect julie-group-guard --json

You want to see something like:

"typedHooks": [
  { "name": "message_sending" }
]

The plain-text inspect should also show a Typed hooks section.

So based on what you pasted, my current read is:

  • plugins.allow is not the issue
#
  • TypeScript compilation is probably not the issue
  • the plugin is loaded
  • the hook is very likely registered as a typed hook

Why it still may not be firing

If typedHooks does show message_sending, then the problem is probably not registration, but one of these:

  1. Wrong runtime / wrong gateway process

    • Julie’s WhatsApp account may be attached to a different running gateway/config than the one you inspected.
  2. Logger visibility

    • your handler may be running, but the log level/category is hiding it.
  3. Outbound path bug

    • message_sending is supposed to run in outbound delivery.
    • If Julie visibly replied and your typed hook is registered, but it still never fires, that smells like a real bug.
#

Also, please include:

openclaw --version
openclaw gateway status
openclaw status --all

Version matters a lot here.

Compaction / identity

Short answer: there is no built-in config for a fixed prefix injected into the compaction summary.

Supported knobs are:

  • agents.defaults.compaction.postCompactionSections
#
  • agents.defaults.contextLimits.postCompactionMaxChars
  • agents.defaults.compaction.model
  • agents.defaults.compaction.identifierPolicy

If you want a literal custom summary format/prefix, that is custom compaction provider territory.

Docs:

Important gotcha with Julie

Post-compaction recovery re-injects AGENTS.md sections, not SOUL.md.

So if Julie’s identity mostly lives in SOUL.md, compaction can absolutely flatten her into generic-assistant sludge.

#

Best supported fix

Put Julie’s critical identity into Julie’s AGENTS.md, under headings like:

  • ## Julie Identity
  • ## Session Startup
  • ## Red Lines

Then configure:

agents: {
  defaults: {
    contextInjection: "always",
    contextLimits: {
      postCompactionMaxChars: 4000
#
    },
    compaction: {
      postCompactionSections: ["Julie Identity", "Session Startup", "Red Lines"]
    }
  }
}

And in those sections, explicitly say:

  • you are Julie
  • you monitor these WhatsApp groups
  • you log every inbound message
  • you must not deliver replies into those groups
  • after compaction, re-read SOUL.md, IDENTITY.md, USER.md
#

My recommendation

Please paste these two things next:

  1. full openclaw plugins inspect julie-group-guard --json top-level typedHooks
  2. openclaw --version

If typedHooks contains message_sending and Julie still replied, I’d treat that as bug territory, not config error.

#

I don’t think that snippet proves a bug.

For typed hooks like api.on("message_sending", ...), this shape is actually expected:

  • plugin.hookNames = custom/legacy hooks
  • plugin.hookCount = total hook count on the plugin record
  • typed hook names are reported separately in typedHooks, not in plugin.hookNames

So:

hookNames: []
hookCount: 1

can simply mean:

#
  • no custom hooks
  • one typed hook registered

Please paste these two things

First, version:

openclaw --version

Then:

openclaw plugins inspect julie-group-guard --json
#

And specifically the top-level fields:

  • typedHooks
  • customHooks
  • plugin

If typedHooks contains message_sending, then the hook is registered and the problem is runtime delivery behavior, not plugin loading.

Short version

This:

plugin.hookNames: []
plugin.hookCount: 1
#

does not mean ā€œhook exists in code but is not registeredā€.

It usually means:

  • registered typed hook
  • no custom hooks

Docs: https://docs.openclaw.ai/cli/plugins and https://docs.openclaw.ai/tools/plugin

If you paste openclaw --version + the typedHooks section, I can tell you pretty quickly whether this is config, reporting confusion, or a real outbound-hook bug.

dire osprey
#
openclaw --version: OpenClaw 2026.4.21 (f788c88)

openclaw plugins inspect julie-group-guard --json:

typedHooks: [{ "name": "message_sending" }]
customHooks: []
hookCount: 1
hookNames: []

Julie visibly replied in a WhatsApp group after gateway restart.
typedHooks shows message_sending is registered. Hook never fires.
merry tokenBOT
#

Yep, this is finally real signal.

What your result proves

  • typedHooks: [{ name: "message_sending" }] means the plugin does register correctly
  • hookNames: [] is irrelevant here, that field is for custom/legacy hooks
  • so this is not a TypeScript registration problem

Important nuance

openclaw plugins inspect ... is a CLI-side inspection path, not proof that the running gateway service loaded the same plugin/config.

So at this point I’d narrow it to one of two things:

  1. runtime mismatch
    • the live gateway is using a different config/process than the CLI inspect path
#
  1. real bug
    • outbound WhatsApp delivery is bypassing message_sending on 2026.4.21

Next checks

Please run:

openclaw gateway status
openclaw status --all
openclaw logs --follow

Then restart the gateway and watch for:

  • plugin load line for julie-group-guard
  • any startup/plugin load error
#
  • hook runner init lines
  • anything mentioning outbound delivery / message_sending

I’m especially suspicious of gateway/config drift here.

Most decisive test

In a test group, temporarily make the hook cancel all WhatsApp outbound, not just Julie-specific/group-specific:

api.on("message_sending", async (_event, ctx) => {
  if (ctx.channelId === "whatsapp") {
    return { cancel: true };
  }
});
#

If a WhatsApp message still lands after restart, then that outbound path is almost certainly bypassing the normal message_sending hook path. That’s bug territory.

Compaction / Julie identity

Separate issue, and still yes:

  • there is no built-in ā€œfixed prefix for compaction summaryā€ knob
  • best supported fix is:
    • keep contextInjection: "always"
    • move Julie’s critical identity into AGENTS.md
    • use agents.defaults.compaction.postCompactionSections
    • increase postCompactionMaxChars if needed

If you want total control over the summary format, that’s a custom compaction provider.

Docs:

#

My honest take

Given what you posted, I would now treat this as:

  • probably runtime mismatch first
  • bug second

If logs confirm the live gateway loaded julie-group-guard and Julie still replied, I’d file a GitHub issue with that exact evidence: https://github.com/openclaw/openclaw/issues

If you paste openclaw gateway status + the relevant startup log chunk, I can help read it.

dire osprey
#

Tenho tudo para o Krill. A linha mais importante do log confirma o runtime mismatch que ele suspeitava:

{"subsystem":"gateway"} "ready (2 plugins: julie-group-guard, whatsapp; 13.5s)"

āœ… O gateway carregou o julie-group-guard. NĆ£o Ć© runtime mismatch.

E o log mostra mensagens indo direto para sendMessageWhatsApp sem nenhuma linha de message_sending hook sendo chamada — confirma o bug real: outbound bypassa o hook.


Gateway startup log:
{"subsystem":"gateway"} "ready (2 plugins: julie-group-guard, whatsapp; 13.5s)"

Plugin loaded. But outbound path goes directly to sendMessageWhatsApp 
with zero message_sending hook lines in between.

Log pattern:
  "Sending message -> sha256:xxx"
  "sending message"  (sendMessageWhatsApp)
  "Sent message xxx"

No hook intercept at any point. This is the bug.
openclaw 2026.4.21 (f788c88)

Isso Ć© evidĆŖncia suficiente para abrir um GitHub issue se o Krill confirmar que Ć© bug.

merry tokenBOT
#

Yep, I’d call the WhatsApp outbound bypass a real bug on 2026.4.21.

Why I’m comfortable saying that:

  • live gateway loaded julie-group-guard
  • typedHooks shows message_sending
  • outbound WhatsApp send still happened
  • logs show send path reaching sendMessageWhatsApp
  • no message_sending interception in between

That’s enough to file an issue: https://github.com/openclaw/openclaw/issues

I’d file two separate issues

1) Bug

Title:
WhatsApp outbound delivery bypasses typed message_sending hook in 2026.4.21

#

Include:

  • openclaw --version
  • openclaw plugins inspect julie-group-guard --json
  • plugin snippet using api.on("message_sending", ...)
  • gateway startup line:
    • ready (2 plugins: julie-group-guard, whatsapp; 13.5s)
  • expected:
    • hook fires, { cancel: true } blocks delivery
  • actual:
    • message goes straight to WhatsApp send path

2) Separate feature gap / bug report

For the compaction/persona problem, I would not bundle it into the same issue.

Something like:

#

Title:
Post-compaction identity recovery is too AGENTS.md-centric and loses SOUL.md persona

That one is a different subsystem.

Best workaround right now

Honestly, for your architecture, I’d stop relying on Julie’s outbound reply path entirely.

Use a message_received hook/plugin to append inbound WhatsApp group messages directly to disk in real time, then let cron/reporting read that file later.

That gives you:

  • real-time capture
  • zero group replies
  • no dependence on message_sending
  • less risk from compaction/persona drift on the hot path
#
  • best supported mitigation is still:
    • contextInjection: "always"
    • move critical Julie identity into AGENTS.md
    • use postCompactionSections
    • raise postCompactionMaxChars if needed

If this helped narrow it down, please right-click the solution message → Apps → āœ… Mark Solution so Answer Overflow can index it.

dire osprey
#

Tenho o snippet. Cole para o Krill no GitHub issue:


Issue 1 — Bug title:
WhatsApp outbound delivery bypasses typed message_sending hook in 2026.4.21

Include:

openclaw --version: OpenClaw 2026.4.21 (f788c88)

Plugin snippet:
api.on("message_sending", async (_event, ctx) => {
  if (ctx.channelId === "whatsapp") {
    return { cancel: true };
  }
});

openclaw plugins inspect julie-group-guard --json:
  typedHooks: [{ "name": "message_sending" }]
  hookCount: 1

Gateway startup log:
  "ready (2 plugins: julie-group-guard, whatsapp; 13.5s)"

Expected: hook fires, { cancel: true } blocks delivery
Actual: outbound goes directly to sendMessageWhatsApp, hook never called

merry tokenBOT
#

Yep, this is enough to file. I’d paste this almost as-is:

Title
WhatsApp outbound delivery bypasses typed message_sending hook in 2026.4.21

Body

## Summary

WhatsApp outbound delivery appears to bypass the typed `message_sending` plugin hook in OpenClaw 2026.4.21.

## Version

- `openclaw --version`: `OpenClaw 2026.4.21 (f788c88)`

## Minimal plugin snippet
#

```ts
api.on("message_sending", async (_event, ctx) => {
  if (ctx.channelId === "whatsapp") {
    return { cancel: true };
  }
});

Plugin inspection

openclaw plugins inspect julie-group-guard --json shows the typed hook is registered:

  • typedHooks: [{ "name": "message_sending" }]
  • customHooks: []
#
- `hookCount: 1`
- `hookNames: []`

So this does not look like a plugin load or registration problem.

## Gateway startup log

```text
ready (2 plugins: julie-group-guard, whatsapp; 13.5s)

This confirms the live gateway loaded both the guard plugin and the WhatsApp plugin.

Expected behavior

#
When outbound delivery is attempted on WhatsApp, the `message_sending` hook should fire first.

Because the hook returns:

```ts
{ cancel: true }

the message should be blocked before delivery.

Actual behavior

The outbound message is still delivered to WhatsApp.

The logs show the send path going directly into sendMessageWhatsApp, with no evidence that message_sending was invoked in between.

#

Observed pattern:

```text
Sending message -> sha256:...
sending message
Sent message ...

No hook interception is visible between those steps.

Why this looks like a bug

  • The plugin is enabled and loaded
  • The typed hook is registered
#
- The live gateway confirms the plugin is active
- Outbound WhatsApp delivery still proceeds
- The hook never appears to run

This suggests WhatsApp outbound delivery is bypassing the normal typed `message_sending` hook path in `2026.4.21`.

## Impact

This breaks plugin-based hard-stop delivery guards for WhatsApp, including setups that need to:

- process inbound messages
- write files / use tools
- suppress all outbound replies

## Workaround
#

Current workaround is to avoid relying on outbound suppression and instead move the hot path to inbound logging (`message_received` / equivalent), then generate reports later from disk.

And yeah, I would file the compaction / Julie identity drift as a separate issue, not bundled into this one.

Issue tracker: https://github.com/openclaw/openclaw/issues

Glad you nailed it down. If this fixed the investigation, please right-click the solution message → Apps → āœ… Mark Solution so Answer Overflow can index it.

grim grailBOT