openclaw - ✅(Solved) Fix [Bug]: WhatsApp (and likely all channels) media silently dropped via message send CLI / agent message tool in 2026.4.5 [1 pull requests, 1 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
openclaw/openclaw#62399Fetched 2026-04-08 03:04:50
View on GitHub
Comments
1
Participants
2
Timeline
6
Reactions
1
Author
Participants
Timeline (top)
cross-referenced ×2commented ×1referenced ×1renamed ×1

In OpenClaw 2026.4.5, calling openclaw message send --media file.jpg (or invoking the agent message tool with media) returns success with a valid messageId, but the recipient only receives the text — the media is silently dropped.

The root cause is in createChannelOutboundRuntimeSend (in the compiled CLI send-runtime helper, currently dist/deps-sT-6fEgQ.js): it always dispatches to outbound.sendText, never to outbound.sendMedia, and channel adapters' sendText implementations do not accept mediaUrl. The media field gets passed to sendText and ignored.

This regression was introduced in the 2026.4.5 channel seam / outbound deps refactors (changelog: "WhatsApp/outbound: restore runtime send/action routing and outbound compatibility after the recent channel seam refactor", "Configure/startup: move outbound send-deps resolution into a lightweight helper", "Build/lazy runtime boundaries: replace ineffective dynamic import sites … CLI send deps").

Error Message

if (!outbound?.sendText) throw new Error(params.unavailableMessage); if (!sendFn) throw new Error(params.unavailableMessage);

Root Cause

createChannelOutboundRuntimeSend in the compiled CLI send-runtime helper (dist/deps-sT-6fEgQ.js):

function createChannelOutboundRuntimeSend(params) {
  return { sendMessage: async (to, text, opts = {}) => {
    const outbound = await loadChannelOutboundAdapter(params.channelId);
    if (!outbound?.sendText) throw new Error(params.unavailableMessage);
    return await outbound.sendText({
      cfg: opts.cfg ?? loadConfig(),
      to,
      text,
      mediaUrl: opts.mediaUrl,            // ← passed to sendText, but sendText ignores it
      mediaLocalRoots: opts.mediaLocalRoots,
      accountId: opts.accountId,
      threadId: opts.messageThreadId,
      replyToId: opts.replyToMessageId == null ? void 0 : String(opts.replyToMessageId).trim() || void 0,
      silent: opts.silent,
      forceDocument: opts.forceDocument,
      gifPlayback: opts.gifPlayback,
      gatewayClientScopes: opts.gatewayClientScopes
    });
  } };
}

The function never inspects opts.mediaUrl / opts.mediaUrls, never dispatches to outbound.sendMedia, and never falls back. It relies on sendText accepting mediaUrl, which the channel adapters do not do.

For WhatsApp, the adapter (createWhatsAppOutboundBase in dist/send-lXVwx8YQ.js) defines sendText and sendMedia separately:

sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => {
  // mediaUrl is NOT destructured — silently dropped
  return await (resolveOutboundSendDep(deps, "whatsapp", ...) ?? sendMessageWhatsApp)(to, normalizedText, { ... });
},
sendMedia: async ({ cfg, to, text, mediaUrl, mediaAccess, mediaLocalRoots, mediaReadFile, accountId, deps, gifPlayback }) => {
  // mediaUrl IS destructured here
  return await (resolveOutboundSendDep(deps, "whatsapp", ...) ?? sendMessageWhatsApp)(to, normalizeText(text), { ..., mediaUrl, ... });
},

The same pattern applies to all channel adapters — they expose sendText/sendMedia as separate functions and rely on the dispatcher to pick the right one. createChannelOutboundRuntimeSend is the dispatcher and it never picks sendMedia.

Fix Action

Workaround

Until a fix ships, patch dist/deps-sT-6fEgQ.js (or whatever the current chunk hash is) with the snippet above and restart the gateway. The patch is wiped on every openclaw package update and must be reapplied, so a proper upstream fix would be greatly appreciated.

PR fix notes

PR #62474: fix(cli/send-runtime): route to sendMedia when media is present in createChannelOutboundRuntimeSend

Description (problem / solution / changelog)

Problem

createChannelOutboundRuntimeSend in src/cli/send-runtime/channel-outbound-send.ts always dispatched to outbound.sendText, ignoring opts.mediaUrl / opts.mediaUrls. Channel adapters (e.g. WhatsApp) expose sendText and sendMedia as separate functions — sendText does not accept or forward mediaUrl, so media was silently dropped while the CLI returned a successful messageId.

This affects both code paths that send messages:

  1. CLIopenclaw message send --media, openclaw message broadcast --media, etc.
  2. Gateway / agents — any agent using the message tool with media.

Regression introduced in the 2026.4.5 channel seam / outbound deps refactors. Worked correctly through 2026.4.2.

Fixes #62399.

Fix

  • Detect media presence via opts.mediaUrl or opts.mediaUrls (non-empty array)
  • Prefer outbound.sendMedia when media is detected and the adapter exposes it; fall back to outbound.sendText otherwise (text-only calls are unchanged)
  • Forward mediaUrls (array form) in addition to mediaUrl so multi-media sends are covered
  • Extend RuntimeSendOpts to type mediaUrls correctly

Testing

Verified locally against WhatsApp: openclaw message send --channel whatsapp --target "+34x" --message "test" --media /tmp/red.png now delivers both the text and the image. Gateway log shows hasMedia: true and the correct sendMedia path is invoked.

Changed files

  • src/cli/send-runtime/channel-outbound-send.ts (modified, +8/-2)

Code Example

# Create a tiny PNG
convert -size 64x64 xc:red /tmp/red.png   # or any image
chmod 644 /tmp/red.png

# Send via CLI
openclaw message send \
  --channel whatsapp \
  --target "+34xxxxxxxxx" \
  --message "test media" \
  --media /tmp/red.png

---

info gateway/channels/whatsapp/outbound  Sending message -> sha256:30a5b12b3a40
info web-outbound {jid:"sha256:30a5b12b3a40", hasMedia:false}  sending message
info gateway/channels/whatsapp/outbound  Sent message 3EB... -> sha256:... (17ms)

---

outboundLog.info(`Sending message -> ${redactedJid}${options.mediaUrl ? " (media)" : ""}`);
logger.info({ jid: redactedJid, hasMedia: Boolean(options.mediaUrl) }, "sending message");

---

function createChannelOutboundRuntimeSend(params) {
  return { sendMessage: async (to, text, opts = {}) => {
    const outbound = await loadChannelOutboundAdapter(params.channelId);
    if (!outbound?.sendText) throw new Error(params.unavailableMessage);
    return await outbound.sendText({
      cfg: opts.cfg ?? loadConfig(),
      to,
      text,
      mediaUrl: opts.mediaUrl,            // ← passed to sendText, but sendText ignores it
      mediaLocalRoots: opts.mediaLocalRoots,
      accountId: opts.accountId,
      threadId: opts.messageThreadId,
      replyToId: opts.replyToMessageId == null ? void 0 : String(opts.replyToMessageId).trim() || void 0,
      silent: opts.silent,
      forceDocument: opts.forceDocument,
      gifPlayback: opts.gifPlayback,
      gatewayClientScopes: opts.gatewayClientScopes
    });
  } };
}

---

sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => {
  // mediaUrl is NOT destructured — silently dropped
  return await (resolveOutboundSendDep(deps, "whatsapp", ...) ?? sendMessageWhatsApp)(to, normalizedText, { ... });
},
sendMedia: async ({ cfg, to, text, mediaUrl, mediaAccess, mediaLocalRoots, mediaReadFile, accountId, deps, gifPlayback }) => {
  // mediaUrl IS destructured here
  return await (resolveOutboundSendDep(deps, "whatsapp", ...) ?? sendMessageWhatsApp)(to, normalizeText(text), { ..., mediaUrl, ... });
},

---

sendMedia: async (caption, mediaUrl, overrides) => {
  if (sendMedia) return sendMedia({ ...resolveCtx(overrides), text: caption, mediaUrl });
  return sendText({ ...resolveCtx(overrides), text: caption });
}

---

function createChannelOutboundRuntimeSend(params) {
  return { sendMessage: async (to, text, opts = {}) => {
    const outbound = await loadChannelOutboundAdapter(params.channelId);
    const hasMedia = Boolean(opts.mediaUrl) || (Array.isArray(opts.mediaUrls) && opts.mediaUrls.length > 0);
    const sendFn = hasMedia && outbound?.sendMedia ? outbound.sendMedia : outbound?.sendText;
    if (!sendFn) throw new Error(params.unavailableMessage);
    return await sendFn({
      cfg: opts.cfg ?? loadConfig(),
      to,
      text,
      mediaUrl: opts.mediaUrl,
      mediaUrls: opts.mediaUrls,           // ← also forward the array form
      mediaLocalRoots: opts.mediaLocalRoots,
      accountId: opts.accountId,
      threadId: opts.messageThreadId,
      replyToId: opts.replyToMessageId == null ? void 0 : String(opts.replyToMessageId).trim() || void 0,
      silent: opts.silent,
      forceDocument: opts.forceDocument,
      gifPlayback: opts.gifPlayback,
      gatewayClientScopes: opts.gatewayClientScopes
    });
  } };
}
RAW_BUFFERClick to expand / collapse

Bug: WhatsApp (and likely all channels) media silently dropped via message send CLI / agent message tool in 2026.4.5

Summary

In OpenClaw 2026.4.5, calling openclaw message send --media file.jpg (or invoking the agent message tool with media) returns success with a valid messageId, but the recipient only receives the text — the media is silently dropped.

The root cause is in createChannelOutboundRuntimeSend (in the compiled CLI send-runtime helper, currently dist/deps-sT-6fEgQ.js): it always dispatches to outbound.sendText, never to outbound.sendMedia, and channel adapters' sendText implementations do not accept mediaUrl. The media field gets passed to sendText and ignored.

This regression was introduced in the 2026.4.5 channel seam / outbound deps refactors (changelog: "WhatsApp/outbound: restore runtime send/action routing and outbound compatibility after the recent channel seam refactor", "Configure/startup: move outbound send-deps resolution into a lightweight helper", "Build/lazy runtime boundaries: replace ineffective dynamic import sites … CLI send deps").

Version

  • OpenClaw 2026.4.5 (commit 3e72c03, current npm latest and beta)
  • Worked correctly through 2026.4.2
  • Reproduced after a clean openclaw doctor --fix, gateway restart, and full reindex

Reproduction

# Create a tiny PNG
convert -size 64x64 xc:red /tmp/red.png   # or any image
chmod 644 /tmp/red.png

# Send via CLI
openclaw message send \
  --channel whatsapp \
  --target "+34xxxxxxxxx" \
  --message "test media" \
  --media /tmp/red.png

Expected: recipient receives "test media" with the image attached. Actual: recipient only receives "test media". CLI returns ok with a messageId. Same result with --media-url, with multiple media paths, and from the agent message tool.

Evidence — gateway logs

info gateway/channels/whatsapp/outbound  Sending message -> sha256:30a5b12b3a40
info web-outbound {jid:"sha256:30a5b12b3a40", hasMedia:false}  sending message
info gateway/channels/whatsapp/outbound  Sent message 3EB... -> sha256:... (17ms)

The log line is generated in dist/send-lXVwx8YQ.js:

outboundLog.info(`Sending message -> ${redactedJid}${options.mediaUrl ? " (media)" : ""}`);
logger.info({ jid: redactedJid, hasMedia: Boolean(options.mediaUrl) }, "sending message");

hasMedia: false confirms options.mediaUrl is undefined by the time sendMessageWhatsApp is invoked.

Root cause

createChannelOutboundRuntimeSend in the compiled CLI send-runtime helper (dist/deps-sT-6fEgQ.js):

function createChannelOutboundRuntimeSend(params) {
  return { sendMessage: async (to, text, opts = {}) => {
    const outbound = await loadChannelOutboundAdapter(params.channelId);
    if (!outbound?.sendText) throw new Error(params.unavailableMessage);
    return await outbound.sendText({
      cfg: opts.cfg ?? loadConfig(),
      to,
      text,
      mediaUrl: opts.mediaUrl,            // ← passed to sendText, but sendText ignores it
      mediaLocalRoots: opts.mediaLocalRoots,
      accountId: opts.accountId,
      threadId: opts.messageThreadId,
      replyToId: opts.replyToMessageId == null ? void 0 : String(opts.replyToMessageId).trim() || void 0,
      silent: opts.silent,
      forceDocument: opts.forceDocument,
      gifPlayback: opts.gifPlayback,
      gatewayClientScopes: opts.gatewayClientScopes
    });
  } };
}

The function never inspects opts.mediaUrl / opts.mediaUrls, never dispatches to outbound.sendMedia, and never falls back. It relies on sendText accepting mediaUrl, which the channel adapters do not do.

For WhatsApp, the adapter (createWhatsAppOutboundBase in dist/send-lXVwx8YQ.js) defines sendText and sendMedia separately:

sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => {
  // mediaUrl is NOT destructured — silently dropped
  return await (resolveOutboundSendDep(deps, "whatsapp", ...) ?? sendMessageWhatsApp)(to, normalizedText, { ... });
},
sendMedia: async ({ cfg, to, text, mediaUrl, mediaAccess, mediaLocalRoots, mediaReadFile, accountId, deps, gifPlayback }) => {
  // mediaUrl IS destructured here
  return await (resolveOutboundSendDep(deps, "whatsapp", ...) ?? sendMessageWhatsApp)(to, normalizeText(text), { ..., mediaUrl, ... });
},

The same pattern applies to all channel adapters — they expose sendText/sendMedia as separate functions and rely on the dispatcher to pick the right one. createChannelOutboundRuntimeSend is the dispatcher and it never picks sendMedia.

Impact scope

This dispatcher is used by both code paths that send messages from OpenClaw:

  1. CLIregister.message-BZtBk61s.js:432 calls createDefaultDeps()createChannelOutboundRuntimeSend per channel. Affects openclaw message send --media, openclaw message broadcast --media, etc.

  2. Gateway / agentsserver-Cv5hzFG4.js:28030 (and lines 21976, 22800) calls createDefaultDeps() at gateway startup and injects the resulting deps map into channel plugins. The plugins resolve deps[<channel>] via resolveOutboundSendDep(deps, channelId) and use it instead of the native send function. Affects every agent that uses the message tool with media.

So both openclaw message send and in-session agent message calls hit the same broken dispatcher. The auto-reply path in dist/deliver-DqZbx7oj.js:554 is unaffected because it has its own correct sendText/sendMedia switching:

sendMedia: async (caption, mediaUrl, overrides) => {
  if (sendMedia) return sendMedia({ ...resolveCtx(overrides), text: caption, mediaUrl });
  return sendText({ ...resolveCtx(overrides), text: caption });
}

Suggested fix

createChannelOutboundRuntimeSend needs to detect media and route accordingly:

function createChannelOutboundRuntimeSend(params) {
  return { sendMessage: async (to, text, opts = {}) => {
    const outbound = await loadChannelOutboundAdapter(params.channelId);
    const hasMedia = Boolean(opts.mediaUrl) || (Array.isArray(opts.mediaUrls) && opts.mediaUrls.length > 0);
    const sendFn = hasMedia && outbound?.sendMedia ? outbound.sendMedia : outbound?.sendText;
    if (!sendFn) throw new Error(params.unavailableMessage);
    return await sendFn({
      cfg: opts.cfg ?? loadConfig(),
      to,
      text,
      mediaUrl: opts.mediaUrl,
      mediaUrls: opts.mediaUrls,           // ← also forward the array form
      mediaLocalRoots: opts.mediaLocalRoots,
      accountId: opts.accountId,
      threadId: opts.messageThreadId,
      replyToId: opts.replyToMessageId == null ? void 0 : String(opts.replyToMessageId).trim() || void 0,
      silent: opts.silent,
      forceDocument: opts.forceDocument,
      gifPlayback: opts.gifPlayback,
      gatewayClientScopes: opts.gatewayClientScopes
    });
  } };
}

I have applied this patch locally to dist/deps-sT-6fEgQ.js and confirmed that WhatsApp media sends from both the CLI and the agent message tool are restored without any other changes.

Likely regression source

From the 2026.4.5 changelog the most likely PRs that introduced or left this gap are the channel seam / send deps refactors:

  • "WhatsApp/outbound: restore runtime send/action routing and outbound compatibility after the recent channel seam refactor"
  • "Configure/startup: move outbound send-deps resolution into a lightweight helper" (#46301)
  • "Build/lazy runtime boundaries: replace ineffective dynamic import sites … CLI send deps" (#33690)

The "restore runtime send/action routing" entry looks like a partial fix for actions (reactions, etc.) that did not cover the media-vs-text dispatch in createChannelOutboundRuntimeSend.

Workaround

Until a fix ships, patch dist/deps-sT-6fEgQ.js (or whatever the current chunk hash is) with the snippet above and restart the gateway. The patch is wiped on every openclaw package update and must be reapplied, so a proper upstream fix would be greatly appreciated.

Environment

  • Linux x86_64 (Ubuntu 24.04)
  • Node 22.x
  • OpenClaw 2026.4.5 (3e72c03), installed via npm i -g openclaw
  • Multiple channels configured (whatsapp default), but the bug is channel-agnostic — it affects any channel whose outbound adapter exposes sendText and sendMedia separately.

extent analysis

TL;DR

The most likely fix for the issue is to update the createChannelOutboundRuntimeSend function to correctly dispatch to outbound.sendMedia when media is present.

Guidance

  • Verify that the createChannelOutboundRuntimeSend function is the root cause of the issue by checking the code and the provided stacktrace.
  • Update the createChannelOutboundRuntimeSend function to detect media and route accordingly, using the suggested fix provided in the issue.
  • Apply the patch to dist/deps-sT-6fEgQ.js and restart the gateway as a temporary workaround until a proper fix is released.
  • Test the fix by sending a message with media using the CLI or agent message tool and verifying that the media is received by the recipient.

Example

The suggested fix for the createChannelOutboundRuntimeSend function is:

function createChannelOutboundRuntimeSend(params) {
  return { sendMessage: async (to, text, opts = {}) => {
    const outbound = await loadChannelOutboundAdapter(params.channelId);
    const hasMedia = Boolean(opts.mediaUrl) || (Array.isArray(opts.mediaUrls) && opts.mediaUrls.length > 0);
    const sendFn = hasMedia && outbound?.sendMedia ? outbound.sendMedia : outbound?.sendText;
    if (!sendFn) throw new Error(params.unavailableMessage);
    return await sendFn({
      cfg: opts.cfg ?? loadConfig(),
      to,
      text,
      mediaUrl: opts.mediaUrl,
      mediaUrls: opts.mediaUrls,
      mediaLocalRoots: opts.mediaLocalRoots,
      accountId: opts.accountId,
      threadId: opts.messageThreadId,
      replyToId: opts.replyToMessageId == null ? void 0 : String(opts.replyToMessageId).trim() || void 0,
      silent: opts.silent,
      forceDocument: opts.forceDocument,
      gifPlayback: opts.gifPlayback,
      gatewayClientScopes: opts.gatewayClientScopes
    });
  } };
}

Notes

The fix is specific to the createChannelOutboundRuntimeSend function and does not affect other parts of the code. The temporary workaround of patching dist/deps-sT-6fEgQ.js will need to be reapplied after every openclaw package update.

Recommendation

Apply the suggested fix to the createChannelOutboundRuntimeSend function to correctly dispatch to outbound.sendMedia when media is present. This fix should resolve the issue and allow media to be sent correctly.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING