#Whatsapp ocr workflow
104 messages Ā· Page 1 of 1 (latest)
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
- User sends image to your WhatsApp-connected OpenClaw number with caption
invoice - A
message:receivedhook checks:channelId === "whatsapp"- caption/content is
invoice mediaPathexists
- Hook runs your Python script with that file path
- Script returns HTML
- Convert HTML to JSON
- 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
- WhatsApp: https://docs.openclaw.ai/channels/whatsapp
- Hooks: https://docs.openclaw.ai/automation/hooks
- Inbound media: https://docs.openclaw.ai/nodes/images
- Exec tool: https://docs.openclaw.ai/tools/exec
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.
openclaw --version
OpenClaw 2026.4.11 (769908e)
can't paste script as discord is blocking it
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.mediaPathpoints 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:
- https://docs.openclaw.ai/automation/hooks
- https://docs.openclaw.ai/channels/whatsapp
- https://docs.openclaw.ai/nodes/images
- https://docs.openclaw.ai/tools/exec
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.
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.
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:
-
your hook is not replacing the normal assistant reply
Internal hooks are additive, so the model still answers unless you suppress it. -
your WhatsApp image + caption are probably arriving as two events
The first reply reacted to the image, the second reacted to the textinvoice.
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.
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)
Thanks, the logs help a lot.
What they show:
- the inbound event already has both
body:"invoice"andmediaPath:"...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
If you donāt need OpenClawās own image understanding here:
{
tools: {
media: {
image: {
enabled: false
}
}
}
}
Docs: https://docs.openclaw.ai/nodes/media-understanding
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
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?
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.
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
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.
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
Nice, this looks correct now.
Key lines:
Status: loadedSource: ~/.openclaw/plugins/openclaw-invoice-suppress/index.tsCustom hooks: suppress-invoice-before-dispatch: before_dispatch
So:
- plugin is loading from the right file
- the
before_dispatchhook 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_dispatchis 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:
- prove hook fires
- narrow matcher
- 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.
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!
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:
- remove the AGENTS/skill
NO_REPLYrule temporarily - keep plugin returning
plugin intercept ok - restart gateway
- 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_REPLYmodel 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.
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?
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
- restart gateway
openclaw gateway restart
- in the WhatsApp chat, send:
/new
- 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.
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.
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_REPLYrule again) - keep your internal hook to run Python and send JSON
- change the plugin so it cancels natural-language outbound replies after an
invoicetrigger, 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_receivedsees the inboundinvoicetriggermessage_sendingsees 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_REPLYAGENTS 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.
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.
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 --versionopenclaw 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
orno reply sent
and Iāll tell you the next exact step.
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.
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.
yes. but while posting issue there are sections
Summary
Steps to reproduce
Expected behavior
Actual behavior
Provider / routing chain
Yep, paste this:
Summary
A local hook-only plugin is loaded successfully, but WhatsApp auto-replies ignore both:
before_dispatchreturning{ handled: true }message_sendingreturning{ cancel: true }
This makes it impossible to suppress the normal WhatsApp auto-reply path for a custom invoice workflow.
Steps to reproduce
- Run OpenClaw
2026.4.12. - Install a local plugin from a path and confirm it loads:
openclaw plugins inspect invoice-suppress
- Register a WhatsApp hook like:
before_dispatchreturning{ handled: true }, ormessage_sendingreturning{ cancel: true }for all WhatsApp outbound sends
- Restart the gateway.
- Send a WhatsApp message to the connected OpenClaw number.
- I tested both a normal text message and an image with caption
invoice.
- I tested both a normal text message and an image with caption
Expected behavior
If the plugin hook returns:
{ handled: true }inbefore_dispatch, the normal model auto-reply should not run.{ cancel: true }inmessage_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:
- removing AGENTS.md rules
- 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_dispatchblock-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.