#Whatsapp ocr workflow

104 messages Ā· Page 1 of 1 (latest)

sick pewter
#

How to implement workflow:

  • user sends invoice image captioning it as invoice to whatsapp number connected to openclaw
  • openclaw should run the python script having sarvamai which returns the html string
  • openclaw should convert it to json and answer user with the json output
plain cometBOT
knotty frigateBOT
#

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

plain cometBOT
outer marshBOT
#

Let me verify the docs path and the relevant OpenClaw docs so I don't make up config details.

#

Need one more pass through the automation docs, because this kind of "when a WhatsApp image with caption invoice arrives, do X" can either be prompt-driven or hook-driven. I want to point you to the cleanest supported path.

#

I’m checking one implementation detail: whether the inbound attachment path is exposed directly enough for a hook, versus using the normal agent/tool path. That changes what I’d recommend.

#

Best way: use an internal hook, not prompt-only logic.

Why:

  • exact trigger on WhatsApp + caption "invoice"
  • direct access to the inbound attachment path
  • cheaper and more reliable than hoping the model always follows the workflow

Recommended flow

  1. User sends image to your WhatsApp-connected OpenClaw number with caption invoice
  2. A message:received hook checks:
    • channelId === "whatsapp"
    • caption/content is invoice
    • mediaPath exists
  3. Hook runs your Python script with that file path
  4. Script returns HTML
  5. Convert HTML to JSON
#
  1. Push the JSON back as the reply text

Minimal shape

hooks/invoice-whatsapp/HOOK.md

---
name: invoice-whatsapp
description: Process WhatsApp invoice images
metadata:
  { "openclaw": { "events": ["message:received"] } }
---

hooks/invoice-whatsapp/handler.ts

#
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import * as cheerio from "cheerio";

const execFileAsync = promisify(execFile);

function htmlToJson(html: string) {
  const $ = cheerio.load(html);

  // replace with your real parsing logic
  return {
    text: $("body").text().trim(),
    tables: $("table").map((_, table) =>
      $(table).find("tr").map((_, tr) =>
        $(tr).find("th,td").map((_, td) => $(td).text().trim()).get()
#
      ).get()
    ).get(),
  };
}

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

  const ctx = event.context ?? {};
  const caption = String(ctx.content ?? "").trim().toLowerCase();

  if (ctx.channelId !== "whatsapp") return;
  if (caption !== "invoice") return;
  if (!ctx.mediaPath) return;
#
  const { stdout } = await execFileAsync("python3", [
    "/absolute/path/to/your/sarvam_script.py",
    ctx.mediaPath,
  ]);

  const json = htmlToJson(stdout);
  event.messages.push(JSON.stringify(json, null, 2));
}

Enable it

openclaw hooks enable invoice-whatsapp
openclaw gateway restart
#

or enable via config under hooks.internal.entries.

Important tip

If you can, make the Python script return JSON directly instead of HTML. That is much cleaner and less brittle.

Docs

If you want, paste your current sarvam_script.py interface and your openclaw --version, and I can turn this into an exact working hook for your setup.

sick pewter
#

openclaw --version
OpenClaw 2026.4.11 (769908e)

can't paste script as discord is blocking it

outer marshBOT
#

No worries, you don’t need to paste the script.

2026.4.11 is recent enough for the hook approach. For your case I’d make it deterministic:

  • WhatsApp image arrives
  • caption is invoice
  • hook grabs mediaPath
  • hook runs a small Python wrapper
  • wrapper calls your existing SarvamAI script, gets HTML, converts to JSON
  • hook replies with that JSON

That keeps OpenClaw out of the brittle HTML parsing part.

1) Hook files

<workspace>/hooks/invoice-whatsapp/HOOK.md

#
---
name: invoice-whatsapp
description: Process WhatsApp invoice images
metadata:
  { "openclaw": { "events": ["message:received"] } }
---

<workspace>/hooks/invoice-whatsapp/handler.ts

import { execFile } from "node:child_process";
import { promisify } from "node:util";

const execFileAsync = promisify(execFile);
#
export default async function handler(event: any) {
  if (event.type !== "message" || event.action !== "received") return;

  const ctx = event.context ?? {};
  const caption = String(ctx.content ?? "").trim().toLowerCase();

  if (ctx.channelId !== "whatsapp") return;
  if (caption !== "invoice") return;
  if (!ctx.mediaPath) return;

  try {
    const { stdout } = await execFileAsync(
      "python3",
      ["/absolute/path/to/invoice_wrapper.py", ctx.mediaPath],
      { maxBuffer: 10 * 1024 * 1024 },
#
    );

    const parsed = JSON.parse(stdout);
    event.messages.push(JSON.stringify(parsed, null, 2));
  } catch (err: any) {
    event.messages.push(
      `Invoice processing failed: ${err?.message ?? String(err)}`
    );
  }
}

2) Python wrapper

Make a tiny wrapper that calls your current script and converts HTML to JSON:

#

/absolute/path/to/invoice_wrapper.py

import json
import subprocess
import sys

def html_to_json(html: str):
    # replace with your real parsing logic
    return {
        "raw_html": html
    }

def main():
    image_path = sys.argv[1]

    html = subprocess.check_output(
#
        ["python3", "/absolute/path/to/your_existing_sarvam_script.py", image_path],
        text=True,
    )

    result = html_to_json(html)
    print(json.dumps(result, ensure_ascii=False))

if __name__ == "__main__":
    main()

3) Enable hooks

In config:

#
{
  hooks: {
    internal: {
      enabled: true,
      entries: {
        "invoice-whatsapp": { enabled: true }
      }
    }
  }
}

Then:

openclaw hooks enable invoice-whatsapp
#
openclaw hooks check
openclaw gateway restart

4) Test

Send to the WhatsApp number:

  • an image
  • caption: invoice

Expected result:

  • hook sees message:received
  • ctx.mediaPath points to the downloaded attachment
  • wrapper returns JSON
  • OpenClaw sends that JSON back to WhatsApp
#

Why I’d do it this way

You originally said ā€œscript returns HTML, OpenClaw converts to JSONā€. That can work, but it’s cleaner to let Python do the HTML parsing and let OpenClaw just route input/output.

Docs:

If you want, I can give you the same setup as a workspace skill instead of a hook, but for this exact trigger the hook is the better fit.

sick pewter
#

did changes, uploaded invoice with caption
below is response

[5:34 pm, 14/4/2026] My Indore: The attached image is a screenshot of a text editor or code editor. The content looks like it was captured while writing some code or documentation within the application.

Would you like me to analyze this image for any patterns, anomalies, or extract useful information from it? Or do you have another purpose in mind?

If you're interested in analyzing the image, please let me know how you'd like that done. For example, do we need to look for specific keywords, syntax coloring hints, or anything else within the text content?
[5:34 pm, 14/4/2026] My Indore: I see that you've sent an invoice. Could you please provide more details? Specifically, what information do you need assistance with? Are there any specific tasks or actions related to the invoice you'd like me to perform?

For instance:

  • Do you need help understanding the terms of the invoice?
  • Should I assist in calculating something (like taxes or shipping charges)?
  • Or are you looking for a way to track payments or manage the invoice within your system?

Please provide as much context and details as possible so I can best support you.

outer marshBOT
#

That’s helpful, and I’m pretty sure I know what happened: the hook reply went out, but the normal agent run still happened too. I’m checking the supported way to short-circuit the default model reply instead of just adding another message on top.

#

I found the core issue: internal hooks are additive. They can send extra messages, but they do not stop the normal assistant reply. I’m looking at the supported interception path now.

#

I confirmed the behavior: internal message:received hooks do not replace the assistant run, they just add side effects. For a hard stop you need the plugin-side inbound_claim path, or a pragmatic NO_REPLY guard in the assistant prompt/skill.

#

Good, there’s a cleaner supported fix than a hook hack: a local plugin with before_dispatch. That hook can return handled: true and stop the normal LLM reply completely. I’m grabbing the minimal plugin shape so I can give you something copy-pasteable.

#

Yep, this tells us two things:

  1. your hook is not replacing the normal assistant reply
    Internal hooks are additive, so the model still answers unless you suppress it.

  2. your WhatsApp image + caption are probably arriving as two events
    The first reply reacted to the image, the second reacted to the text invoice.

That matches the behavior in https://docs.openclaw.ai/concepts/messages too: media flushes immediately, text can become a separate turn.

Quick fix

Update the hook so it supports split delivery:

  • if a media message arrives, cache mediaPath
  • if a later text message says invoice, use the cached media from the same chat
#

Better hook shape

import { execFile } from "node:child_process";
import { promisify } from "node:util";

const execFileAsync = promisify(execFile);

// simple in-memory cache for recent inbound media
const recentMedia = new Map<string, { mediaPath: string; ts: number }>();
const TTL_MS = 15_000;

function keyFor(ctx: any) {
  return `${ctx.channelId}:${ctx.from ?? ""}`;
}
#
export default async function handler(event: any) {
  if (event.type !== "message" || event.action !== "received") return;

  const ctx = event.context ?? {};
  if (ctx.channelId !== "whatsapp") return;

  const key = keyFor(ctx);
  const now = Date.now();

  // store media if present
  if (ctx.mediaPath) {
    recentMedia.set(key, { mediaPath: ctx.mediaPath, ts: now });
  }

  const text = String(ctx.content ?? "").trim().toLowerCase();
#

  let mediaPath: string | undefined;

  // case 1: caption + media arrived together
  if (text === "invoice" && ctx.mediaPath) {
    mediaPath = ctx.mediaPath;
  }

  // case 2: caption arrived separately after media
  if (!mediaPath && text === "invoice") {
    const cached = recentMedia.get(key);
    if (cached && now - cached.ts <= TTL_MS) {
      mediaPath = cached.mediaPath;
    }
  }
#

  if (!mediaPath) return;

  try {
    const { stdout } = await execFileAsync(
      "python3",
      ["/absolute/path/to/invoice_wrapper.py", mediaPath],
      { maxBuffer: 10 * 1024 * 1024 },
    );

    const parsed = JSON.parse(stdout);
    event.messages.push(JSON.stringify(parsed, null, 2));
    recentMedia.delete(key);
  } catch (err: any) {
    event.messages.push(`Invoice processing failed: ${err?.message ?? String(err)}`);
#
  }
}

Then suppress the normal assistant reply

Add a narrow rule in your agent instructions or AGENTS.md:

If a WhatsApp invoice OCR hook/workflow is handling the message, reply exactly NO_REPLY.
If the user sends only `invoice` as a caption/trigger for that workflow, reply exactly NO_REPLY.

That stops the generic ā€œhow can I help with this invoice?ā€ reply from leaking out.

Production-grade fix

#

If you want this fully deterministic, move it from an internal hook to a local plugin using before_dispatch. That hook can stop the model run entirely. But for now, the cache + NO_REPLY guard is the fastest fix.

If you want, I can give you the exact plugin version next.

sick pewter
#

please give plugin version as it is not working at all. below is openclaw logs

12:21:35+00:00 info web-inbound {"module":"web-inbound"} {"from":"+919111292909","to":"+919109411221","body":"invoice","mediaPath":"/home/atish/.openclaw/media/inbound/WhatsApp_Image_2026-04-11_at_3.02.57_PM---be81ccf2-7882-4aa1-95a6-34814486ad16.jpg","mediaType":"image/jpeg","mediaFileName":"WhatsApp Image 2026-04-11 at 3.02.57 PM.jpeg","timestamp":1776169294000} inbound message
12:21:35+00:00 info web-auto-reply {"module":"web-auto-reply","runId":"4fae7b5f-3ff8-40a2-aa2d-d17918e560f3"} {"connectionId":"7dcd0319-6dc5-4d0e-a77c-34ece458cc5b","correlationId":"3EB0C10BC06C46AEA91B15","from":"+919111292909","to":"+919109411221","body":"[WhatsApp +919111292909 +1m Tue 2026-04-14 17:51 GMT+5:30] invoice","mediaType":"image/jpeg","mediaPath":"/home/atish/.openclaw/media/inbound/WhatsApp_Image_2026-04-11_at_3.02.57_PM---be81ccf2-7882-4aa1-95a6-34814486ad16.jpg"} inbound web message
12:21:35+00:00 info gateway/channels/whatsapp/inbound {"subsystem":"gateway/channels/whatsapp/inbound"} Inbound message +919111292909 -> +919109411221 (direct, image/jpeg, 66 chars)
12:21:35+00:00 warn media-understanding {"subsystem":"media-understanding"} image: failed (0/1) reason=Unknown model
12:21:42+00:00 info web-auto-reply {"module":"web-auto-reply","runId":"4fae7b5f-3ff8-40a2-aa2d-d17918e560f3"} {"correlationId":"3EB0C10BC06C46AEA91B15","connectionId":"7dcd0319-6dc5-4d0e-a77c-34ece458cc5b","to":"+919111292909","from":"+919109411221","text":"It appears you've sent an invoice message. For the image I received, it seems like there might have been a misunderstanding or error in your message format. Let's try to clarify:\n\nYou mentioned sending an invoice and also provided an imag… (truncated 239 chars)","mediaUrl":null,"mediaSizeBytes":null,"mediaKind":null,"durationMs":6} auto-reply sent (text)

outer marshBOT
#

Thanks, the logs help a lot.

What they show:

  • the inbound event already has both body:"invoice" and mediaPath:"...jpg"
  • the normal OpenClaw auto-reply still ran 7s later
  • so the missing piece is suppressing the default model reply

Also, that Unknown model warning is separate. You can ignore it for this flow, or disable image media-understanding for now.

Important truth

A plugin-only version is awkward here, because the easy plugin interception hook (before_dispatch) can stop the model reply, but it does not expose mediaPath.

So the clean setup is:

  • internal hook = runs your Python wrapper and sends JSON
#
  • local plugin = suppresses the normal assistant reply

That’s the combo I’d use.


1) Suppressor plugin

This plugin only prevents the generic WhatsApp auto-reply for invoice.

package.json

{
  "name": "openclaw-invoice-suppress",
  "version": "1.0.0",
  "type": "module",
#
  "openclaw": {
    "extensions": ["./index.ts"]
  }
}

openclaw.plugin.json

{
  "id": "invoice-suppress",
  "name": "Invoice Suppress",
  "description": "Suppress normal auto-reply for invoice workflow",
  "configSchema": {
    "type": "object",
    "additionalProperties": false
#
  }
}

index.ts

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

export default definePluginEntry({
  id: "invoice-suppress",
  name: "Invoice Suppress",
  description: "Suppress normal auto-reply for invoice workflow",
  register(api) {
    api.registerHook("before_dispatch", async (event, ctx) => {
      if (ctx.channelId !== "whatsapp") return;
#

      const text = String(event.content ?? "").trim().toLowerCase();

      if (text === "invoice") {
        return { handled: true };
      }

      return;
    });
  },
});

install it

#
openclaw plugins install /absolute/path/to/openclaw-invoice-suppress
openclaw plugins enable invoice-suppress
openclaw gateway restart

2) Keep your internal hook for the real work

Your hook should still do the actual processing, since it has mediaPath.

Minimal handler shape:

import { execFile } from "node:child_process";
#
import { promisify } from "node:util";

const execFileAsync = promisify(execFile);

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

  const ctx = event.context ?? {};
  const text = String(ctx.content ?? "").trim().toLowerCase();

  if (ctx.channelId !== "whatsapp") return;
  if (text !== "invoice") return;
  if (!ctx.mediaPath) return;

  try {
#
    const { stdout } = await execFileAsync(
      "python3",
      ["/absolute/path/to/invoice_wrapper.py", ctx.mediaPath],
      { maxBuffer: 10 * 1024 * 1024 }
    );

    event.messages.push(stdout.trim()); // wrapper should print JSON
  } catch (err: any) {
    event.messages.push(`Invoice processing failed: ${err?.message ?? String(err)}`);
  }
}

3) Disable noisy image-understanding warning for now

#

4) Sanity check

After restart, expected behavior:

  • user sends WhatsApp image with caption invoice
  • plugin stops the normal AI auto-reply
  • internal hook runs invoice_wrapper.py <mediaPath>
  • wrapper prints JSON
  • hook sends JSON back to WhatsApp

If you want, next I can give you a known-good invoice_wrapper.py template that:

  • calls your current SarvamAI script
  • converts returned HTML to JSON
  • prints JSON for OpenClaw to send back
sick pewter
#

this is also not working. reply received:

I see you've sent an invoice message again. To ensure we're not missing any important details, let's proceed with the image attachment.

Could you please provide more context or instructions on what you would like me to do with this image? Are you looking for item details or some other specific action?

If you have additional messages or tasks in mind, feel free to share them. Otherwise, I'll move forward with processing the attached image.
Would you like to proceed now or do something else?

Do i have to clear session or something to make it work or what else to do?

outer marshBOT
#

No, don’t clear the session. This is almost certainly not a session problem.

The likely issue is your matcher is too strict.

Your logs show OpenClaw is building a body like:

[WhatsApp +919111292909 +1m Tue 2026-04-14 17:51 GMT+5:30] invoice

So if your plugin/hook checks:

text === "invoice"

it can miss.

#

Fix both plugin and hook to match loosely

Plugin

Replace the matcher with this:

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

export default definePluginEntry({
  id: "invoice-suppress",
  name: "Invoice Suppress",
  description: "Suppress normal auto-reply for invoice workflow",
  register(api) {
    api.registerHook("before_dispatch", async (event, ctx) => {
      if (ctx.channelId !== "whatsapp") return;
#
      const haystack = `${event.content ?? ""}\n${event.body ?? ""}`.toLowerCase();

      if (!/\binvoice\b/.test(haystack)) return;

      return { handled: true };
    });
  },
});

Internal hook

Also loosen the hook matcher:

const haystack = `${ctx.content ?? ""}\n${ctx.body ?? ""}`.toLowerCase();
#

if (ctx.channelId !== "whatsapp") return;
if (!/\binvoice\b/.test(haystack)) return;
if (!ctx.mediaPath) return;

Then restart gateway

openclaw gateway restart

Verify it actually loaded

Run these:

#
openclaw plugins inspect invoice-suppress
openclaw plugins list --enabled
openclaw hooks check

What you want to see:

  • plugin is loaded/enabled
  • hook is enabled/healthy

One more thing

This warning is unrelated to the suppression problem:

warn media-understanding image: failed (0/1) reason=Unknown model
#

You can disable it for now:

{
  tools: {
    media: {
      image: {
        enabled: false
      }
    }
  }
}

Short version

  • No session clear needed
#
  • Use regex / contains match, not === "invoice"
  • Restart gateway
  • Check plugin/hook are actually loaded

If you paste the output of:

openclaw plugins inspect invoice-suppress
openclaw hooks check

I can tell you exactly which piece is still not loading.

sick pewter
#

openclaw plugins inspect invoice-suppress

Invoice Suppress
id: invoice-suppress
Suppress normal auto-reply for invoice workflow

Status: loaded
Format: openclaw
Source: ~/.openclaw/extensions/invoice-suppress/index.ts
Origin: global
Version: 1.0.0
Shape: non-capability
Capability mode: none
Legacy before_agent_start: no

Diagnostics:
WARN: hook registration missing name

Install:
Source: path
Source path: ~/.openclaw/plugins/openclaw-invoice-suppress
Install path: ~/.openclaw/extensions/invoice-suppress
Recorded version: 1.0.0
Installed at: 2026-04-14T12:34:04.012Z

Hooks Status

Total hooks: 6
Ready: 6
Not ready: 0

outer marshBOT
#

Ah, I think I see it.

You do not need to clear the session.

The important line is this:

Source path: ~/.openclaw/plugins/openclaw-invoice-suppress
Install path: ~/.openclaw/extensions/invoice-suppress
Source: ~/.openclaw/extensions/invoice-suppress/index.ts

That means OpenClaw is running the copied install under ~/.openclaw/extensions/..., not your original folder.

So if you edited:

#
~/.openclaw/plugins/openclaw-invoice-suppress

those changes will not apply until you reinstall the plugin.

Do this

Option A, quickest

openclaw plugins install ~/.openclaw/plugins/openclaw-invoice-suppress --force
openclaw gateway restart

Option B, better for development

Use a linked install so edits apply from your source folder:

#
openclaw plugins uninstall invoice-suppress --keep-files
openclaw plugins install -l ~/.openclaw/plugins/openclaw-invoice-suppress
openclaw gateway restart

Also fix the warning

Add a hook name:

api.registerHook(
  "before_dispatch",
  async (event, ctx) => {
#
    if (ctx.channelId !== "whatsapp") return;

    const haystack = `${event.content ?? ""}\n${event.body ?? ""}`.toLowerCase();
    if (!/\binvoice\b/.test(haystack)) return;

    return { handled: true };
  },
  { name: "suppress-invoice-before-dispatch" }
);

Sanity check

After reinstall, confirm the loaded file actually has your new code:

#
grep -n "suppress-invoice-before-dispatch\\|before_dispatch\\|invoice" \
  ~/.openclaw/extensions/invoice-suppress/index.ts

If you use -l, then the extension should point at your real source path instead.


Short version

Your plugin is loaded, but you are probably editing the source folder, while OpenClaw is executing the copied install folder.

That’s why it feels like ā€œnothing changedā€.

If you want, after you reinstall it, send:

#
openclaw plugins inspect invoice-suppress
openclaw hooks check

and I’ll verify the next step.

sick pewter
#

openclaw plugins inspect invoice-suppress

šŸ¦ž OpenClaw 2026.4.12 (1c0672b) — No $999 stand required.

Invoice Suppress
id: invoice-suppress
Suppress normal auto-reply for invoice workflow

Status: loaded
Format: openclaw
Source: ~/.openclaw/plugins/openclaw-invoice-suppress/index.ts
Origin: config
Version: 1.0.0
Shape: hook-only
Capability mode: none
Legacy before_agent_start: no

Compatibility warnings:
invoice-suppress is hook-only. This remains a supported compatibility path, but it has not migrated to explicit capability registration yet.

Custom hooks:
suppress-invoice-before-dispatch: before_dispatch

Install:
Source: path
Source path: ~/.openclaw/plugins/openclaw-invoice-suppress
Install path: ~/.openclaw/plugins/openclaw-invoice-suppress
Recorded version: 1.0.0
Installed at: 2026-04-14T13:10:31.048Z

outer marshBOT
#

Nice, this looks correct now.

Key lines:

  • Status: loaded
  • Source: ~/.openclaw/plugins/openclaw-invoice-suppress/index.ts
  • Custom hooks: suppress-invoice-before-dispatch: before_dispatch

So:

  • plugin is loading from the right file
  • the before_dispatch hook is registered
  • no session clear needed

The hook-only warning is harmless here.

Next step, do a binary test

#

Temporarily make the plugin intercept all WhatsApp messages so we prove the hook is firing.

Replace the hook body with this:

api.registerHook(
  "before_dispatch",
  async (_event, ctx) => {
    if (ctx.channelId !== "whatsapp") return;
    return { handled: true, text: "plugin intercept ok" };
  },
  { name: "suppress-invoice-before-dispatch" }
);

Then:

#
openclaw gateway restart

Then send any WhatsApp message.

Expected result

You should get exactly:

plugin intercept ok

What that tells us

  • If you get plugin intercept ok, the plugin path is good, and the only remaining issue is your invoice matcher.
  • If you still get the normal AI reply, then before_dispatch is not being hit on your auto-reply path, which would be surprising, and we’ll debug that specifically.
#

If the binary test works

Then change it back to invoice-only:

api.registerHook(
  "before_dispatch",
  async (event, ctx) => {
    if (ctx.channelId !== "whatsapp") return;

    const haystack = `${event.content ?? ""}\n${event.body ?? ""}`.toLowerCase();

    if (!/\binvoice\b/.test(haystack)) return;

    return { handled: true };
  },
#
  { name: "suppress-invoice-before-dispatch" }
);

and restart again.

My read

You’re finally past the install/load mess. Now it’s just:

  1. prove hook fires
  2. narrow matcher
  3. keep the internal hook doing the actual JSON reply

If you test the plugin intercept ok version and tell me what WhatsApp sends back, I can pin the last issue fast.

sick pewter
#

NO_REPLY

This message seems to be an invoice trigger for a workflow. I will not send any response and let the associated system handle it properly. If you have more questions or need assistance with something else, feel free to ask!

outer marshBOT
#

That reply is not the plugin working, it’s the model talking.

And it shows the exact problem:

NO_REPLY only works when the assistant returns exactly NO_REPLY and nothing else.

Right now it’s returning:

NO_REPLY
This message seems to be...

That leaks through.

What to do

#

1) Pick one suppression method while testing

Right now you’ve got two things mixed together:

  • plugin suppression
  • prompt/AGENTS/skill-based NO_REPLY

That makes debugging messy.

For the next test, do one of these:

Option A, test plugin only

Remove/disable the NO_REPLY prompt rule for now.

Use the plugin test version:

#
return { handled: true, text: "plugin intercept ok" };

If you still get a model reply, then the plugin is not intercepting the path.

Option B, test model suppression only

Keep the hook, forget the plugin for a minute, and make the model rule much stricter:

If this is the WhatsApp invoice workflow trigger, reply with exactly:
NO_REPLY

Do not add any other text.
Do not explain.
Do not add punctuation.
#
Do not add a second line.

Valid reply:
NO_REPLY

Invalid replies:
NO_REPLY.
NO_REPLY\nHandled.
This is NO_REPLY

My recommendation right now

Do Option A first.

Because if the plugin is working, you should see:

#
plugin intercept ok

and definitely not that NO_REPLY explanation.

So for now:

  1. remove the AGENTS/skill NO_REPLY rule temporarily
  2. keep plugin returning plugin intercept ok
  3. restart gateway
  4. send any WhatsApp message

Interpretation

  • If you get plugin intercept ok, great, plugin path works.
  • If you still get a normal AI reply, the plugin is not intercepting this auto-reply path and we should stop fighting it and use the exact-NO_REPLY model route instead.
#

So no, don’t clear the session yet. First isolate plugin vs model.

If you want, I can give you the exact minimal AGENTS snippet for the bare-NO_REPLY route next.

sick pewter
#

removed no_reply prompt rule from AGENTS.md now getting below response

Here's the image I received. Is there anything specific you'd like to do with it? You can ask for a description, share it elsewhere, or use another tool if needed. If you need more information about this image, let me know how I can assist further! 😊

Would you like to see some text-based analysis or perform any other action on the image?

outer marshBOT
#

Yeah, this pretty much confirms it: the plugin path is still not suppressing this auto-reply on your setup.

At this point I’d stop burning time on that and use the route that was already almost working:

Use exact NO_REPLY, but make it stricter

Put this in AGENTS.md exactly:

## Invoice OCR workflow

If the message is from WhatsApp, contains the word `invoice`, and includes an image or document attachment:

- do not describe the image
- do not ask follow-up questions
- do not explain what you are doing
#
- do not offer help
- reply with exactly: NO_REPLY
- output nothing before or after NO_REPLY

Valid reply:
NO_REPLY

Invalid replies:
NO_REPLY.
NO_REPLY
Handled.
This is an invoice trigger. NO_REPLY

Then do this

#
  1. restart gateway
openclaw gateway restart
  1. in the WhatsApp chat, send:
/new
  1. test again with the invoice image + caption invoice

I’m recommending /new now not because the plugin needs it, but because that chat already has a bunch of confusing prior turns in it.

Also disable image-understanding noise for now

That generic ā€œhere’s the image I receivedā€ chatter is not helping here.

#
{
  tools: {
    media: {
      image: {
        enabled: false
      }
    }
  }
}

Why I’m steering this way

You already got a response that started with NO_REPLY, which means the model was close. It just wasn’t constrained tightly enough.

That is much closer to working than the plugin path right now.

#

If you want, I can give you a final known-good AGENTS.md block + hook code together as one clean copy-paste bundle.

sick pewter
#

changed AGENTS.md also disabled image-understanding noise. now getting

I see an invoice image was sent. Please describe the content or details you need help with, and I'll assist accordingly.

outer marshBOT
#

Okay, this is the point where I’d stop trying to make the model behave šŸ˜…

The practical fix is:

  • keep AGENTS clean (remove the NO_REPLY rule again)
  • keep your internal hook to run Python and send JSON
  • change the plugin so it cancels natural-language outbound replies after an invoice trigger, but allows JSON through

That avoids relying on the model at all.

Replace the plugin with this

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

const pendingInvoice = new Map<string, number>();
#
const TTL_MS = 30_000;

function looksJson(text: string): boolean {
  const s = text.trim();
  return s.startsWith("{") || s.startsWith("[");
}

export default definePluginEntry({
  id: "invoice-suppress",
  name: "Invoice Suppress",
  description: "Suppress natural-language auto replies for invoice workflow",
  register(api) {
    api.registerHook(
      "message_received",
      async (event, ctx) => {
#
        if (ctx.channelId !== "whatsapp") return;

        const text = String(event.content ?? "").toLowerCase();
        if (/\binvoice\b/.test(text)) {
          pendingInvoice.set(event.from, Date.now() + TTL_MS);
        }
      },
      { name: "track-invoice-trigger" },
    );

    api.registerHook(
      "message_sending",
      async (event, ctx) => {
        if (ctx.channelId !== "whatsapp") return;
#
        const until = pendingInvoice.get(event.to);
        if (!until) return;

        if (Date.now() > until) {
          pendingInvoice.delete(event.to);
          return;
        }

        const text = String(event.content ?? "");

        // allow hook-generated JSON replies through
        if (looksJson(text)) {
          pendingInvoice.delete(event.to);
          return;
        }
#

        // cancel normal chatty assistant replies
        return { cancel: true };
      },
      { name: "cancel-natural-language-after-invoice" },
    );
  },
});

Why this should work

  • message_received sees the inbound invoice trigger
  • message_sending sees the actual outbound assistant text
  • if it’s normal English chatter, cancel it
  • if it’s JSON from your hook, let it through
#

So we stop trying to suppress the model before generation, and instead suppress the unwanted send at delivery time.

Then

openclaw gateway restart

and test again with:

  • invoice image
  • caption invoice

Keep / remove

  • keep: your internal hook that runs the Python wrapper
  • keep: image-understanding disabled
  • remove: the NO_REPLY AGENTS rule
#

Important assumption

This assumes your hook reply is plain JSON text starting with { or [.

If your hook sends fenced JSON like:

```json
{ ... }

tell me, and I’ll tweak the filter.

I’m much more confident in this version than the earlier prompt-based route.
sick pewter
#

AGENTS.md has nothing in it, changed index.ts in plugin, restarted the gateway. now getting

I see an invoice image was sent. Please describe the content or details you need help with, and I'll assist accordingly.

outer marshBOT
#

Okay, that’s useful. At this point I don’t think you’re doing it wrong. I think the WhatsApp auto-reply path is just not honoring the plugin hook the way it should on your build.

Let’s do one final binary test.

Replace the plugin with the most brutal version possible

This should block every WhatsApp outbound reply, no exceptions:

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

export default definePluginEntry({
  id: "invoice-suppress",
  name: "Invoice Suppress",
  description: "Block all WhatsApp outbound messages for debugging",
  register(api) {
#
    api.registerHook(
      "message_sending",
      async (_event, ctx) => {
        if (ctx.channelId !== "whatsapp") return;
        return { cancel: true };
      },
      { name: "block-all-whatsapp-outbound" },
    );
  },
});

Then:

#
openclaw gateway restart

Then send any WhatsApp message.

Expected result

You should get no reply at all.

Interpretation

  • If you still get a WhatsApp reply, then this is very likely a real bug / gap in how plugin hooks apply to WhatsApp auto-replies on 2026.4.12.
  • If no reply comes through, then the hook works and we can narrow it back down.

My honest read

#

Because you already have:

  • plugin loaded
  • custom hook registered
  • source path correct
  • gateway restarted

…and you’re still getting normal replies, I’m leaning toward bug.

If the block-all test still fails

Please file a GitHub issue with:

  • openclaw --version
  • openclaw plugins inspect invoice-suppress
  • the tiny plugin above
  • one sample log showing WhatsApp auto-reply still sending

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

#

If you want, after you run the block-all test, paste just:

  • reply still sent
    or
  • no reply sent

and I’ll tell you the next exact step.

sick pewter
#

it is also not working...

Hello! How can I assist you today? Using the model ollama/qwen2.5:3b. If there's anything specific you need help with, please let me know.

outer marshBOT
#

Yeah, that’s enough evidence. I’d stop iterating on this path.

A plugin that returns { cancel: true } for all WhatsApp outbound sends should not let any reply through. Since it still does, this looks like a real bug / gap in the WhatsApp auto-reply path on your build, not a session problem or prompt problem.

My recommendation

Please file a GitHub issue here:

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

Minimal repro to paste

Title

WhatsApp auto-reply ignores plugin hooks (message_sending cancel / before_dispatch handled)
#

Body

OpenClaw version: 2026.4.12

Repro:
1. Install a local hook-only plugin.
2. Register a `message_sending` hook that returns `{ cancel: true }` for all WhatsApp outbound sends.
3. Restart the gateway.
4. Send any message to the connected WhatsApp number.

Expected:
- no reply is sent

Actual:
- normal model auto-reply is still sent
#
Also tried:
- `before_dispatch` returning `{ handled: true }`
- removing AGENTS.md rules
- restarting gateway
- confirming plugin is loaded via `openclaw plugins inspect`

Plugin inspect output:
- Status: loaded
- Custom hooks present
- Source path correct

Workaround for now

If you need this working today, I would move this specific invoice workflow outside the normal agent auto-reply path:

#
  • use your WhatsApp-side automation / external script to detect the inbound image + caption
  • run your SarvamAI Python script
  • convert HTML to JSON
  • send the JSON reply directly

I know that’s less elegant, but right now it’s the reliable route.

One extra note

The tiny local model shown in the fallback reply is also not a great fit for tool-heavy automation, so even after the hook bug is fixed, I’d test this workflow with a more tool-reliable setup. Docs: https://docs.openclaw.ai/concepts/models and community notes: https://docs.openclaw.ai/concepts/models#model-discussions-community-notes

If you want, I can help you write the GitHub issue text cleanly in one final message so you can just paste it.

sick pewter
#

yes. but while posting issue there are sections

Summary
Steps to reproduce
Expected behavior
Actual behavior
Provider / routing chain

outer marshBOT
#

Yep, paste this:

Summary

A local hook-only plugin is loaded successfully, but WhatsApp auto-replies ignore both:

  • before_dispatch returning { handled: true }
  • message_sending returning { cancel: true }

This makes it impossible to suppress the normal WhatsApp auto-reply path for a custom invoice workflow.

Steps to reproduce

  1. Run OpenClaw 2026.4.12.
  2. Install a local plugin from a path and confirm it loads:
    • openclaw plugins inspect invoice-suppress
  3. Register a WhatsApp hook like:
    • before_dispatch returning { handled: true }, or
    • message_sending returning { cancel: true } for all WhatsApp outbound sends
  4. Restart the gateway.
#
  1. Send a WhatsApp message to the connected OpenClaw number.
    • I tested both a normal text message and an image with caption invoice.

Expected behavior

If the plugin hook returns:

  • { handled: true } in before_dispatch, the normal model auto-reply should not run.
  • { cancel: true } in message_sending, no WhatsApp outbound reply should be delivered.

Actual behavior

A normal WhatsApp auto-reply is still sent.

Examples of replies that still came through:

  • Hello! How can I assist you today?...
  • I see an invoice image was sent. Please describe the content or details you need help with...

This happened even after:

#
  • restarting the gateway
  • confirming the plugin was loaded
  • using a plugin variant that should block all WhatsApp outbound replies

Provider / routing chain

  • Channel: WhatsApp Web
  • Inbound path: web-inbound / gateway/channels/whatsapp/inbound
  • Auto-reply path: web-auto-reply
  • Plugin type: local hook-only OpenClaw plugin
  • Plugin status: loaded
  • Plugin source: ~/.openclaw/plugins/openclaw-invoice-suppress/index.ts
  • Custom hooks tested:
    • suppress-invoice-before-dispatch: before_dispatch
    • block-all-whatsapp-outbound: message_sending
  • Model backend: local Ollama-backed model was used for the normal fallback reply

If you want, append this too:

#
Plugin inspect showed:
- Status: loaded
- Custom hooks present
- Source path correct
- Gateway restarted after changes

Glad we narrowed it down, even if it’s a bug.