openclaw - ✅(Solved) Fix [Bug]: Per-agent identity overlay dropped on cron --announce and heartbeat target-channel Slack pushes (announce path; reply path was fixed in #38235) [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#84297Fetched 2026-05-20 03:41:39
View on GitHub
Comments
1
Participants
2
Timeline
9
Reactions
1
Timeline (top)
labeled ×6commented ×1cross-referenced ×1referenced ×1

Per-agent identity overlay configured via agents.list[<id>].identity is not applied to outbound Slack messages on the cron --announce and heartbeat target: "slack" paths, while the reply path correctly applies it post-#38235.

Root Cause

Per-agent identity overlay configured via agents.list[<id>].identity is not applied to outbound Slack messages on the cron --announce and heartbeat target: "slack" paths, while the reply path correctly applies it post-#38235.

Fix Action

Fixed

PR fix notes

PR #84335: fix(slack): forward per-agent identity overlay on heartbeat and runtimeSend (#84297)

Description (problem / solution / changelog)

Summary

  • Problem: per-agent identity overlay (agents.list[].identity) is dropped on heartbeat target-channel pushes and through the legacy CLI runtimeSend factory, and even when identity does reach the Slack adapter, the helper that maps OutboundIdentity.emoji onto chat.postMessage.icon_emoji filters out raw Unicode emoji (e.g. 📟) and only accepts :shortcode: form. So Slack messages from heartbeat sends render under the generic app identity instead of the configured agent persona ("Pulse 📟" / etc.). The reply path applies it correctly post-#38235; the announce/push path was never wired up, and the adapter's emoji filter compounds the problem because agents.list[].identity.emoji is commonly configured as raw Unicode.
  • Solution: thread identity from the heartbeat runner and the legacy CLI runtimeSend factory through to the channel adapter, and stop filtering raw Unicode emoji in the Slack outbound adapter so the value the user configured actually reaches chat.postMessage.icon_emoji.
  • What changed: RuntimeSendOpts.identity?: OutboundIdentity typed and forwarded to every adapter entrypoint (sendText / sendMedia / sendPayload); heartbeat-runner now calls resolveAgentOutboundIdentity(cfg, agentId) once next to outboundSession and threads it into both sendDurableMessageBatch calls; resolveSlackSendIdentity in extensions/slack/src/outbound-adapter.ts now passes identity.emoji through unchanged, mirroring how the reply path at src/monitor/message-handler/dispatch.ts already forwards the raw value. New unit tests for the runtime-send factory, a focused end-to-end test that exercises runHeartbeatOnce with a real Slack test plugin, and 4 outbound-adapter tests covering raw Unicode passthrough, :shortcode: passthrough, iconUrl-wins exclusivity, and the no-identity case.
  • What did NOT change (scope boundary): the cron --announce path. On current main it already routes through resolveAgentOutboundIdentity in src/cron/delivery.ts (line 68 / 105). The reporter's source trace was on the v2026.5.18 dist bundle, where cron and heartbeat both went through the legacy runtime-send factory; cron has since moved to sendDurableMessageBatch with identity already wired. The reply path is also untouched (already fixed by #38235), the existing missing-scope retry that strips custom identity for workspaces without chat:write.customize is unchanged, and icon_url still wins over icon_emoji when both are set (Slack's API treats them as mutually exclusive).

Motivation

Multi-agent Slack channels rely on the per-agent identity overlay to disambiguate which agent posted what. Without it, every cron-driven digest and every heartbeat-target push from every agent appears under a single generic app bot name and icon, which forces operators into ugly workarounds (per-agent text prefixes in every template, or splitting each agent onto its own channel and losing the shared-channel synthesis benefit). The reporter calls this out in #84297 with a clean source trace pinpointing the gap in the runtime-send factory; ClawSweeper's review then surfaced the related raw-Unicode emoji filter in the Slack adapter that would have left the persona half-broken even after the threading fix.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor required for the fix
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

  • Closes #84297
  • Related #38235 (reply-path identity fix this PR complements)
  • This PR fixes a bug or regression

Real behavior proof (required for external PRs)

  • Behavior or issue addressed: per-agent Slack identity overlay (agents.list[].identityname / emoji / avatarUrl) is applied on heartbeat target-channel pushes and through the legacy CLI runtimeSend factory, and the raw Unicode emoji form (e.g. 📟) configured by the reporter is preserved end-to-end through the Slack outbound adapter onto chat.postMessage.icon_emoji.
  • Real environment tested: I don't have a live Slack workspace with a chat:write.customize bot wired up from this machine. To exercise the patched code path end to end without a live channel, I ran the runtime semantics for createChannelOutboundRuntimeSend.buildContext(), the heartbeat-runner outbound-identity wiring, and the Slack adapter's resolveSlackSendIdentity mapping directly under node. Terminal capture below shows the actual stdout from node verify-identity-fix.mjs after the patch.
  • Exact steps or command run after this patch:
    node verify-identity-fix.mjs
    (The script inlines the post-patch buildContext(), the heartbeat identity wiring, and the new Slack resolveSlackSendIdentity from extensions/slack/src/outbound-adapter.ts, then drives 6 scenarios against them — including the raw Unicode mapping that ClawSweeper flagged as P2.)
  • Evidence after fix (terminal capture, real stdout):
Verifying identity propagation through runtime-send + heartbeat-runner + Slack adapter (issue #84297)

[1/6] runtime sendText receives identity                       (name=Pulse, emoji=📟)
[2/6] runtime sendMedia receives identity                       (name=Pulse)
[3/6] runtime omits identity when caller does not supply it    (identity=undefined)
[4/6] heartbeat-runner threads identity into both batches      (name=Pulse, emoji=📟)
[5/6] Slack adapter preserves raw Unicode emoji on icon_emoji  (iconEmoji=📟) ← reviewer P2
[6/6] Slack adapter still accepts :shortcode: form for callers (iconEmoji=:beeper:)

All 6 scenarios pass. Per-agent persona overlay reaches chat.postMessage with raw Unicode emoji preserved.
  • Observed result after fix: RuntimeSendOpts.identity reaches ChannelOutboundContext.identity on every send shape; runHeartbeatOnce resolves the agent's outbound identity once next to outboundSession and threads it into both sendDurableMessageBatch calls (heartbeat-ok + main reply); resolveSlackSendIdentity now passes raw Unicode emoji through unchanged so chat.postMessage receives the configured icon_emoji value verbatim. The existing missing-scope fallback path is unchanged, so workspaces without chat:write.customize still degrade gracefully to the app-level identity (same behavior as before).
  • What was not tested: a live Slack workspace run with a real bot token and a manual heartbeat fire to visually confirm the bubble renders under the per-agent persona. I do not have a workspace with chat:write.customize wired up from here. The reporter's auth.test curl in #84297 confirms scope is granted on the affected deployment, so the OC-side wiring is the load-bearing change; visual confirmation in a real workspace is the last remaining live check a maintainer (or @openclaw-mantis slack desktop smoke ...) can run.
  • Before evidence: the original issue write-up (#84297 evidence sections A/B/C) documents the broken behavior on v2026.5.18 — chat:write.customize granted, buildContext() source dropping opts.identity, and the delivery-mirror grep showing zero username / icon_emoji / icon_url references.

Root Cause

  • Root cause: two layered gaps. (1) heartbeat-runner.ts never resolved or forwarded the per-agent identity overlay — both sendDurableMessageBatch calls (heartbeat-ok at ~L1701 and the main heartbeat reply at ~L1981) passed session: outboundSession and deps: opts.deps but no identity. (2) createChannelOutboundRuntimeSend's buildContext() in channel-outbound-send.ts dropped opts.identity on the legacy CLI runtime-send path. (3) Even after threading identity through, the Slack adapter's resolveSlackSendIdentity filtered OutboundIdentity.emoji through /^:[^:\s]+:$/ and dropped raw Unicode emoji to undefined, so agents.list[].identity.emoji: "📟" would have been silently stripped before reaching chat.postMessage.icon_emoji even with the threading fix.
  • Missing detection / guardrail: there was no test asserting that the resolved per-agent identity actually reached the channel adapter on the announce/heartbeat path, and no test on the Slack adapter side that exercised raw Unicode emoji input. #38235 added the reply-path wiring but only covered the streaming reply context-construction; the heartbeat path constructs its own send-batch shape that was never covered by that fix or its tests, and the adapter's emoji filter was never asserted against the raw-Unicode case the reporter actually configures.
  • Contributing context: cron --announce migrated off the legacy runtimeSend factory onto sendDurableMessageBatch between v2026.5.18 and current main, and that migration happened to thread identity correctly. Heartbeat went through the same migration but kept the bug. The reporter's source trace was on the v2026.5.18 dist where both paths were still broken; on main the cron half is already correct, the heartbeat half is not, and the Slack adapter's emoji filter has been quietly degrading any caller (cron included) that configures emojis the way actual operators do.

Regression Test Plan

  • Coverage level that should have caught this:
    • Unit test
    • Seam / integration test
    • End-to-end test
    • Existing coverage already sufficient
  • Target test or file: src/cli/send-runtime/channel-outbound-send.test.ts (4 new scenarios), src/infra/heartbeat-runner.identity.test.ts (new file, 2 scenarios driving runHeartbeatOnce against a real Slack test plugin), extensions/slack/src/outbound-adapter.test.ts (4 new scenarios covering the raw Unicode mapping, the :shortcode: passthrough, the iconUrl-wins exclusivity, and the no-identity case).
  • Scenario the test should lock in: when agents.list[<id>].identity is configured, the resolved OutboundIdentity reaches the channel adapter on every send shape that runtimeSend exposes (sendText / sendMedia / sendPayload), the heartbeat runner threads it into the third arg of the registered Slack send dep on both heartbeat-ok and main heartbeat reply paths, and resolveSlackSendIdentity preserves the configured emoji value verbatim — whether raw Unicode (📟) or shortcode (:beeper:). When no identity is configured, the field is left undefined so the previous behavior is preserved.
  • Why this is the smallest reliable guardrail: the bug class lives in three specific places — the runtime-send factory's buildContext(), the heartbeat-runner's sendDurableMessageBatch calls, and the Slack adapter's emoji mapping. All three are now covered with focused tests that don't require a live Slack workspace. The integration test exercises the real runHeartbeatOnce code path through the existing heartbeat-runner.test-harness Slack plugin, so any future regression that drops identity at any of the three layers fails one of these tests.
  • Existing test that already covers this: nothing for the announce/heartbeat path or the raw-emoji mapping. The existing send.identity-fallback.test.ts exercises the missing-scope retry but only with :shortcode: form, which never tripped the filter.
  • If no new test is added: N/A, new tests are added.

User-visible / Behavior Changes

Heartbeat-driven Slack messages now render under the configured per-agent persona for workspaces that grant chat:write.customize, matching the behavior of the reply path post-#38235. agents.list[].identity.emoji may now be a raw Unicode emoji ("📟") and will be applied verbatim to chat.postMessage.icon_emoji, where previously only :shortcode: form was honored on this adapter path. Workspaces without chat:write.customize continue to fall back to the app-level identity through the existing retry, same as before. Other channels with identity-overlay support (Discord webhook routing, Feishu cards) automatically benefit from the runtime-send factory fix if they reach it. No config flag, no new field — the existing agents.list[].identity config is just honored on paths that previously dropped or filtered it.

Diagram

Before (heartbeat path):
[heartbeat tick] -> resolveDeliveryTarget -> outboundSession
                                          -> sendDurableMessageBatch({ session, deps })
                                                                  ↑  identity dropped here
                                          -> Slack adapter sees ctx.identity = undefined
                                          -> chat.postMessage with no username/icon overrides
                                          -> message renders under generic app identity

Before (Slack adapter, even when identity does arrive):
ctx.identity.emoji = "📟"
  -> resolveSlackSendIdentity()
       -> /^:[^:\s]+:$/.test("📟") === false
       -> iconEmoji = undefined  ← raw Unicode silently dropped
  -> chat.postMessage with no icon_emoji
  -> message renders under generic app identity

After (heartbeat path):
[heartbeat tick] -> resolveDeliveryTarget -> outboundSession
                                          -> resolveAgentOutboundIdentity(cfg, agentId)
                                          -> sendDurableMessageBatch({ session, identity, deps })
                                          -> Slack adapter resolveSlackSendIdentity(ctx.identity)
                                          -> chat.postMessage with username + icon_emoji
                                          -> message renders under "Pulse 📟"

After (Slack adapter):
ctx.identity.emoji = "📟"   (or ":beeper:")
  -> resolveSlackSendIdentity()
       -> iconEmoji = ctx.identity.emoji   ← raw value passed through
  -> chat.postMessage with icon_emoji = "📟"
  -> message renders under the configured persona

Legacy runtimeSend factory (channel-outbound-send.ts):
Before: buildContext() -> { cfg, to, text, ... gatewayClientScopes }   (identity silently dropped)
After:  buildContext() -> { cfg, to, text, ... gatewayClientScopes, identity }

Security Impact (required)

  • New permissions/capabilities? No
  • Secrets/tokens handling changed? No
  • New/changed network calls? No — same chat.postMessage call, just with username / icon_emoji / icon_url populated from existing config when the agent has an identity overlay.
  • Command/tool execution surface changed? No
  • Data access scope changed? No
  • If any Yes, explain risk + mitigation: N/A

Repro + Verification

Environment

  • Integration/channel: Slack via @openclaw/slack plugin, socket mode, bot scope must include chat:write.customize for the overlay to take effect server-side. Heartbeat configured with target: "slack" and to: "channel:C0XXXXXXX".
  • Relevant config (redacted):
agents:
  list:
    - id: pulse
      identity:
        name: Pulse
        emoji: 📟
      heartbeat:
        every: 2h
        target: slack
        to: channel:C0XXXXXXX
        accountId: default

Steps

  1. Run a heartbeat tick against an agent with identity configured (runHeartbeatOnce or wait for the scheduler) targeting a Slack channel.
  2. Watch the resulting Slack message in the target channel.
  3. Before this patch: the message renders under the app's generic bot name + default icon. After this patch: it renders under "Pulse 📟" for any workspace that grants chat:write.customize, regardless of whether the configured emoji is raw Unicode or :shortcode:.

Expected

  • The heartbeat-driven Slack message displays the per-agent name and the configured emoji (raw Unicode or shortcode) from agents.list[].identity, matching the reply path's behavior post-#38235.

Actual

  • With this patch applied, the heartbeat runner resolves the agent's outbound identity once next to outboundSession and forwards it into both sendDurableMessageBatch calls, the legacy runtimeSend factory now also forwards identity, and the Slack adapter's resolveSlackSendIdentity preserves the raw emoji value so chat.postMessage.icon_emoji carries it through.

Human Verification (required)

  • Verified scenarios:
    • RuntimeSendOpts.identity reaches ChannelOutboundContext.identity for sendText, sendMedia, and sendPayload (block) sends.
    • runtimeSend.sendMessage(...) leaves identity undefined when the caller does not supply it (no behavior change for unconfigured callers).
    • runHeartbeatOnce resolves the agent's outbound identity from agents.list[].identity and threads it into the third arg of the Slack send dep used by the heartbeat test plugin.
    • When no identity is configured for the agent, the heartbeat send leaves identity undefined.
    • resolveSlackSendIdentity preserves raw Unicode emoji (📟) on iconEmoji.
    • resolveSlackSendIdentity still accepts the :shortcode: form (":beeper:") for callers that explicitly use it.
    • iconUrl wins over iconEmoji when both are configured (Slack API exclusivity preserved).
    • Cron --announce already passes identity on main (src/cron/delivery.ts line 68 / 105) — verified by reading the existing path; no new wiring needed there.
  • Edge cases checked: agent with name only / emoji only / both / neither (resolveAgentOutboundIdentity returns undefined in the all-empty case, so the field stays undefined). Heartbeat-ok message and the main heartbeat reply send both carry the same resolved identity. Other channels (Discord webhook, Feishu cards) inherit the runtime-send factory fix if they reach it. Workspaces without chat:write.customize hit the existing missing-scope retry that strips custom identity and re-sends — unchanged path.
  • What I did not verify: a live Slack workspace bot with chat:write.customize end to end (no spare bot/workspace from this machine). The reporter's prerequisite auth.test curl in #84297 confirms scope is granted on the affected deployment, so the OC-side wiring is the load-bearing change; visual confirmation in a real workspace would be the last step a maintainer can run, ideally via @openclaw-mantis slack desktop smoke: verify an agent heartbeat to Slack with identity name and emoji renders under that persona.

Review Conversations

  • I replied to or resolved every bot review conversation I addressed in this PR.
  • I left unresolved only the conversations that still need reviewer or maintainer judgment.

Compatibility / Migration

  • Backward compatible? Yes. RuntimeSendOpts.identity is optional. Callers that don't set it (or agents without an identity config) get exactly the previous behavior. Existing callers that pass :shortcode: form to resolveSlackSendIdentity continue to work unchanged. The Slack adapter's missing-scope retry already handles workspaces without chat:write.customize.
  • Config/env changes? No. The agents.list[].identity config already exists and was already being read by other code paths (resolveAckReaction, resolveAssistantIdentity, the reply path post-#38235) — this PR just stops dropping it on the announce/heartbeat path and stops filtering raw Unicode at the adapter layer.
  • Migration needed? No.
  • If yes, exact upgrade steps: N/A.

Risks and Mitigations

  • Risk: a workspace without chat:write.customize will silently ignore the new username / icon_emoji / icon_url fields, leaving messages under the generic app identity.
    • Mitigation: this is Slack-side server behavior, not new to this PR — workspaces already in that shape were never going to render the per-agent persona regardless. The existing reply-path overlay (post-#38235) has the same constraint, and sendMessageSlack already retries without custom identity when it sees a chat:write.customize scope error.
  • Risk: a previously-misconfigured agents.list[].identity.emoji with arbitrary text (e.g. "hello world") would now be passed through to chat.postMessage.icon_emoji instead of being silently filtered out by the old shortcode regex. Slack will reject or render an :emoji_not_found: placeholder.
    • Mitigation: the OutboundIdentity.emoji field is documented as an emoji, and the configurer is responsible for providing a valid value. Slack's failure mode here is a benign "icon does not render" rather than a delivery error — the message body still posts. Same forgiveness shape as the reply path which already passes the raw value through.
  • Risk: heartbeat-runner resolves identity once per heartbeat tick; if the config hot-reloads between tick start and the second sendDurableMessageBatch call, the heartbeat-ok and main reply could in theory carry slightly different snapshots.
    • Mitigation: identity is resolved once at the top of runHeartbeatOnce and reused for both calls in the same tick, so the heartbeat-ok and main reply always carry the identical snapshot. A subsequent tick will pick up any hot-reloaded config naturally.
  • Risk: an unrelated downstream caller of runtimeSend.sendMessage that previously expected buildContext() to omit identity could in theory read a value where none was read before.
    • Mitigation: the field is only populated when the caller passes it in opts.identity. No production caller on main does that today (cron already routes through sendDurableMessageBatch, and heartbeat now does the same). The legacy runtimeSend callers are CLI debug tools and extension code paths that pass the context straight through to channel adapters, which already handled the missing-identity case.

Changed files

  • CHANGELOG.md (modified, +1/-0)
  • extensions/slack/src/outbound-adapter.test.ts (modified, +82/-0)
  • extensions/slack/src/outbound-adapter.ts (modified, +11/-1)
  • src/cli/send-runtime/channel-outbound-send.test.ts (modified, +90/-0)
  • src/cli/send-runtime/channel-outbound-send.ts (modified, +14/-0)
  • src/infra/heartbeat-runner.identity.test.ts (added, +124/-0)
  • src/infra/heartbeat-runner.ts (modified, +10/-0)

Code Example

curl -sS -i -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
  https://slack.com/api/auth.test | grep -i x-oauth-scopes

---

"agents": { "list": [{
     "id": "pulse",
     "identity": { "name": "Pulse", "emoji": "📟" },
     "heartbeat": {
       "every": "2h",
       "target": "slack",
       "to": "channel:C0XXXXXXX",
       "accountId": "default"
     },
     // …
   }]}

---

openclaw cron add --agent pulse --announce \
     --channel slack --to "channel:C0XXXXXXX" --account default \
     --cron "0 8 * * *" --tz "America/Los_Angeles" \
     --message "Post a one-line status."

---

(No screenshots. Three redacted log excerpts supporting the case.)

**A. `chat:write.customize` scope is granted to the bot.**


$ curl -sS -i -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
    https://slack.com/api/auth.test
HTTP/2 200
content-type: application/json; charset=utf-8
x-oauth-scopes: chat:write,chat:write.customize,app_mentions:read,
                files:write,im:write,mpim:write,pins:write,
                reactions:write,assistant:write,commands,
                channels:read,channels:history,files:read,
                groups:history,groups:read,im:history,im:read,
                mpim:history,mpim:read,pins:read,reactions:read,
                emoji:read,usergroups:read,users:read,
                canvases:read,canvases:write,incoming-webhook

{
    "ok": true,
    "url": "https://<workspace>.slack.com/",
    "team": "<team-name-redacted>",
    "user": "<bot-user-redacted>",
    "team_id": "T0XXXXXXXXX",
    "user_id": "U0XXXXXXXXX",
    "bot_id": "B0XXXXXXXXX",
    "is_enterprise_install": false
}


**B. Smoking-gun source excerpt** — the runtime `buildContext()` from the installed OC core bundle on this host. Constructs the channel-adapter context **without `identity`**:


// /usr/lib/node_modules/openclaw/dist/channel-outbound-send-<hash>.js
// source path per the //#region comment:
//   src/cli/send-runtime/channel-outbound-send.ts

function createChannelOutboundRuntimeSend(params) {
    return { sendMessage: async (to, text, opts = {}) => {
        const outbound = await loadChannelOutboundAdapter(params.channelId);
        const threadId = resolveRuntimeThreadId(opts);
        const replyToId = resolveRuntimeReplyToId(opts);
        const buildContext = () => ({
            cfg: opts.cfg ?? getRuntimeConfig(),
            to,
            text,
            mediaUrl: opts.mediaUrl,
            mediaAccess: opts.mediaAccess,
            mediaLocalRoots: opts.mediaLocalRoots,
            mediaReadFile: opts.mediaReadFile,
            accountId: opts.accountId,
            threadId,
            replyToId,
            silent: opts.silent,
            forceDocument: opts.forceDocument,
            formatting: opts.formatting ?? (opts.textMode === "html"
                ? { parseMode: "HTML" } : void 0),
            gifPlayback: opts.gifPlayback,
            gatewayClientScopes: opts.gatewayClientScopes
            // ← no `identity: opts.identity` here
        });
        // …


**C. Delivery-mirror record from the 2026-05-19 17:57 UTC manual cron fire** has zero references to `username` / `icon_emoji` / `icon_url`. Checked both the cron-wrapper session and the agent-side session:


$ for f in d597d133-fc74-.jsonl 4cb4d260-0b6b-.jsonl; do
    grep -cE "username|icon_emoji|icon_url" "$f"
  done
0
0


(Note: delivery-mirror records capture the agent's outbound *text*, not the literal `chat.postMessage` payload OC sent. The absence of these fields here is consistent with — though not definitive proof of — the source-trace conclusion in B. Source trace is the load-bearing evidence; this is supporting.)
RAW_BUFFERClick to expand / collapse

Bug type

Behavior bug (incorrect output/state without crash)

Beta release blocker

No

Summary

Per-agent identity overlay configured via agents.list[<id>].identity is not applied to outbound Slack messages on the cron --announce and heartbeat target: "slack" paths, while the reply path correctly applies it post-#38235.

Steps to reproduce

Precondition: the Slack app installed for the bot must grant chat:write.customize (the scope required to set username / icon_emoji / icon_url on chat.postMessage). Verify with:

curl -sS -i -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
  https://slack.com/api/auth.test | grep -i x-oauth-scopes

chat:write.customize must appear in the comma-separated scope list. (In our deployment it does — confirmed via this exact call — so scope is not the gap. Including the step explicitly so triage can rule scope out in one curl.)

  1. Configure a per-agent identity in openclaw.json:
    "agents": { "list": [{
      "id": "pulse",
      "identity": { "name": "Pulse", "emoji": "📟" },
      "heartbeat": {
        "every": "2h",
        "target": "slack",
        "to": "channel:C0XXXXXXX",
        "accountId": "default"
      },
      // …
    }]}
  2. Install a cron job that uses --announce:
    openclaw cron add --agent pulse --announce \
      --channel slack --to "channel:C0XXXXXXX" --account default \
      --cron "0 8 * * *" --tz "America/Los_Angeles" \
      --message "Post a one-line status."
  3. Manually fire the job: openclaw cron run <job-id> --token "$OPENCLAW_GATEWAY_TOKEN".
  4. Inspect the resulting message in the target Slack channel.

For the comparison case (reply path, which works): in the same channel, send @<bot-name> ping as a user; the agent's reply renders under "Pulse 📟".

Expected behavior

The cron/heartbeat-driven message renders in Slack under the per-agent persona ("Pulse" + 📟), matching the behavior of the reply path post-#38235 in the same gateway version against the same Slack workspace.

Actual behavior

The cron/heartbeat-driven message renders under the generic Slack app's bot name and default icon (the app-level "DemandBots APP" identity for our app installation), with no username / icon_emoji / icon_url override applied to the underlying chat.postMessage call. Same agent in same channel responds to inbound @<bot> mentions correctly under "Pulse 📟".

Source-trace evidence on the installed 2026.5.18 build pinpoints the gap to OC core's runtime sendMessage factory dropping opts.identity before reaching the channel adapter:

src/cli/send-runtime/channel-outbound-send.tscreateChannelOutboundRuntimeSend's buildContext() closure constructs the channel-adapter context with { cfg, to, text, mediaUrl, mediaAccess, mediaLocalRoots, mediaReadFile, accountId, threadId, replyToId, silent, forceDocument, formatting, gifPlayback, gatewayClientScopes }identity is absent. The @openclaw/slack plugin's outbound path (extensions/slack/src/outbound-adapter.tssend.ts) honors identity overlay correctly when present in its context — verified end-to-end in source — but never receives it from this code path.

The reply path uses a different context-construction (the bundled reply-payload runtime invokes adapter.sendText({ ...params.ctx, text: chunk, replyToId: nextReplyToId() }), and params.ctx carries identity resolved via resolveAgentIdentity(cfg, agentId) from src/agents/identity.ts). That's the half #38235 fixed.

OpenClaw version

2026.5.18 (gateway start log: Started openclaw-gateway.service - OpenClaw Gateway (v2026.5.7) — the systemd unit description is stale, but openclaw --version reports OpenClaw 2026.5.18 (50a2481) and the same string appears in [gateway] starting logs). @openclaw/slack plugin: v2026.5.18, enabled, installed at ~/.openclaw/npm/node_modules/@openclaw/slack/.

Operating system

Debian GNU/Linux 12 (bookworm), kernel 6.1.0-41-amd64, x86_64. Hetzner VPS.

Install method

npm global. Binary at /usr/bin/openclaw (shebang #!/usr/bin/env node), sources at /usr/lib/node_modules/openclaw/dist/. Node.js v22.22.2. Gateway managed as a user-level systemd unit (~/.config/systemd/user/openclaw-gateway.service).

Model

claude-haiku-4-5

Provider / routing chain

openclaw → openrouter → anthropic (claude-haiku-4-5)

Additional provider/model setup details

  • Slack: socket mode, streaming.mode: "off", nativeTransport: true (inert while mode is off), groupPolicy: "allowlist", per-channel requireMention set per use case.
  • Bot scopes (from auth.testx-oauth-scopes): chat:write, chat:write.customize, app_mentions:read, files:write, im:write, mpim:write, pins:write, reactions:write, assistant:write, commands, channels:read, channels:history, files:read, groups:history, groups:read, im:history, im:read, mpim:history, mpim:read, pins:read, reactions:read, emoji:read, usergroups:read, users:read, canvases:read, canvases:write, incoming-webhook. chat:write.customize is present, so scope is not the gap.
  • Per-agent identity confirmed read elsewhere in OC (used by ackReaction fallback in src/agents/identity.ts and by resolveAssistantIdentity for the control UI), so the config is being loaded — it just isn't propagated to the runtime-send context constructed by createChannelOutboundRuntimeSend.
  • Cron job example wiring: --announce --channel slack --to "channel:C0XXXXXXX" --account default --best-effort-deliver --expect-final --session-key "agent:<id>:cron:<program>" --stagger 30s — standard.

Logs, screenshots, and evidence

(No screenshots. Three redacted log excerpts supporting the case.)

**A. `chat:write.customize` scope is granted to the bot.**


$ curl -sS -i -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
    https://slack.com/api/auth.test
HTTP/2 200
content-type: application/json; charset=utf-8
x-oauth-scopes: chat:write,chat:write.customize,app_mentions:read,
                files:write,im:write,mpim:write,pins:write,
                reactions:write,assistant:write,commands,
                channels:read,channels:history,files:read,
                groups:history,groups:read,im:history,im:read,
                mpim:history,mpim:read,pins:read,reactions:read,
                emoji:read,usergroups:read,users:read,
                canvases:read,canvases:write,incoming-webhook

{
    "ok": true,
    "url": "https://<workspace>.slack.com/",
    "team": "<team-name-redacted>",
    "user": "<bot-user-redacted>",
    "team_id": "T0XXXXXXXXX",
    "user_id": "U0XXXXXXXXX",
    "bot_id": "B0XXXXXXXXX",
    "is_enterprise_install": false
}


**B. Smoking-gun source excerpt** — the runtime `buildContext()` from the installed OC core bundle on this host. Constructs the channel-adapter context **without `identity`**:


// /usr/lib/node_modules/openclaw/dist/channel-outbound-send-<hash>.js
// source path per the //#region comment:
//   src/cli/send-runtime/channel-outbound-send.ts

function createChannelOutboundRuntimeSend(params) {
    return { sendMessage: async (to, text, opts = {}) => {
        const outbound = await loadChannelOutboundAdapter(params.channelId);
        const threadId = resolveRuntimeThreadId(opts);
        const replyToId = resolveRuntimeReplyToId(opts);
        const buildContext = () => ({
            cfg: opts.cfg ?? getRuntimeConfig(),
            to,
            text,
            mediaUrl: opts.mediaUrl,
            mediaAccess: opts.mediaAccess,
            mediaLocalRoots: opts.mediaLocalRoots,
            mediaReadFile: opts.mediaReadFile,
            accountId: opts.accountId,
            threadId,
            replyToId,
            silent: opts.silent,
            forceDocument: opts.forceDocument,
            formatting: opts.formatting ?? (opts.textMode === "html"
                ? { parseMode: "HTML" } : void 0),
            gifPlayback: opts.gifPlayback,
            gatewayClientScopes: opts.gatewayClientScopes
            // ← no `identity: opts.identity` here
        });
        // …


**C. Delivery-mirror record from the 2026-05-19 17:57 UTC manual cron fire** has zero references to `username` / `icon_emoji` / `icon_url`. Checked both the cron-wrapper session and the agent-side session:


$ for f in d597d133-fc74-….jsonl 4cb4d260-0b6b-….jsonl; do
    grep -cE "username|icon_emoji|icon_url" "$f"
  done
0
0


(Note: delivery-mirror records capture the agent's outbound *text*, not the literal `chat.postMessage` payload OC sent. The absence of these fields here is consistent with — though not definitive proof of — the source-trace conclusion in B. Source trace is the load-bearing evidence; this is supporting.)

Impact and severity

  • Affected: Any OpenClaw deployment running multi-agent setups on Slack where agents.list[<id>].identity is intended to disambiguate agents in shared channels via per-agent name/emoji, and where outbound messages are driven by cron --announce or heartbeat.target: "slack" rather than only by reply to inbound mentions. In our six-agent demand-gen deployment this affects every cron-driven digest and every heartbeat-target push from the perf-monitor, campaign-architect, and seo-geo agents.
  • Severity: Annoying — blocks visual disambiguation of which agent posted in shared channels but does not block any workflow, lose any data, or produce incorrect message content. Bot/app-level identity still posts the message correctly.
  • Frequency: Always. Every cron/heartbeat-driven Slack message posts under the generic app identity instead of the per-agent persona. Verified on the 2026-05-19 17:57 UTC manual cron fire on 2026.5.18.
  • Consequence: In a multi-agent Slack channel, recipients can't tell from Slack's native bot-name treatment which agent posted a given message. Forces operators to either (a) ship a per-agent text prefix in every outbound template (extra maintenance, visible-in-message), or (b) split each agent onto its own dedicated Slack channel (loses the shared-channel synthesis benefit).

Additional information

  • Related: #38235 (closed 2026-05-13). That issue's fix landed on OC main and addressed the streaming reply path. The announce/push path uses createChannelOutboundRuntimeSend (a separate context-construction in src/cli/send-runtime/channel-outbound-send.ts) and was not touched.
  • Proposed fix shape (two small mechanical changes, both in OC core; the slack plugin needs no changes):
    1. In src/cli/send-runtime/channel-outbound-send.ts, add identity: opts.identity to the object returned by buildContext().
    2. In the cron / heartbeat runtime callers that invoke runtimeSend.sendMessage(to, text, opts), populate opts.identity from resolveAgentIdentity(cfg, agentId) (already exported from src/agents/identity.ts and used by assistant-identity.ts and the response-generator's agent-name resolution).
  • Other channel plugins with identity-overlay support (Discord webhook routing, Feishu cards) likely benefit automatically from change (1) if their announce path goes through the same runtime factory — worth checking as part of the fix.
  • Not a regression in any version we've observed. We have run 2026.5.7 → 2026.5.12 → 2026.5.18; the announce/heartbeat persona overlay has not worked in any of them. Reporting as a behavior bug rather than a regression accordingly.

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…

FAQ

Expected behavior

The cron/heartbeat-driven message renders in Slack under the per-agent persona ("Pulse" + 📟), matching the behavior of the reply path post-#38235 in the same gateway version against the same Slack workspace.

Still need to ship something?

×6

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

Back to top recommendations

TRENDING

openclaw - ✅(Solved) Fix [Bug]: Per-agent identity overlay dropped on cron --announce and heartbeat target-channel Slack pushes (announce path; reply path was fixed in #38235) [1 pull requests, 1 comments, 2 participants]