openclaw/docs/concepts/progress-drafts.md
Ayaan Zaidi 24a03acf58
fix(discord): retain progress cards while subagents continue (#153028)
Related: #101656
Extends #152517 to Discord.

## What Problem This Solves

Discord deleted the useful progress card and replaced it with a generic waiting message when a parent yielded to subagents that were still working.

## User Impact

With Discord progress streaming enabled, the existing card keeps its checklist and receives child activity and terminal updates after parent yield. The final answer remains a separate message. Streaming defaults, quiet policies, configuration, and database schemas are unchanged.

Media/control replies and declined or unconfirmed handoffs keep normal delivery. Ordinary non-delegated progress drafts still clean up after final delivery.

## Why This Change Was Made

Discord now adopts the existing core continuation capability using a confirmed message receipt. Core continues to own task lifecycle, persistence, cancellation, and restart; the Discord adapter only renders and edits the retained card. No second task registry or completion path is introduced.

The adapter preserves account and thread/DM routing, rejects stale or unconfirmed display state, and disables mention parsing on retained edits. The registered edit action uses the host-prepared snapshot rather than replacing the checklist with generic task text.

## Evidence

Final live recordings used commit `1f4f670f37bfd8d60046de159191a7ff4ea87d89`, a clean built checkout, isolated Gateway state, and leased QA bots:

- Baseline `779e6cfc04` reproduced the original card deletion and generic replacement while two real subagents continued running and returned command-generated results.
- Completion: nine edits to the same card, both workers succeeded, all three checklist steps completed, zero card deletions, and one separate final answer; 14 provider requests.
- Cancellation: both executing workers became cancelled on the original card, no successful-result claim, and no card edits for the remaining 131 seconds of recording; 16 provider requests. The separate cancellation-turn temporary draft was cleaned up normally.
- Restart: both workers were observed executing before restart; the same card and checklist returned afterward; 13 provider requests. Recovered agents completed their reports, but explicitly described the interrupted commands' outcomes as unknown, without inventing successful command results or retrying them.
- 202 focused tests passed across seven Discord suites, including confirmed/declined handoff, failed edits, oversized content, stale authority, thread/DM routing, media/control fallback, and ordinary final-delivery cleanup.
- Changed-code checks passed, including extension production/test typechecks, scoped lint, import boundaries, dead exports, and line-cap checks. Documentation checks passed. The supported CLI rebuilt successfully. Independent P0–P2 review found no actionable issues.
- The follow-up commit documents the optional `allowedMentions` edit field: it maps to Discord `allowed_mentions`; omission sends no field and preserves existing edit behavior. That follow-up changes documentation only, not the runtime exercised above.

These are real leased-bot Discord transport recordings with a deterministic model fixture and real OpenClaw tools/subagents, not native slash-command, component-click, or human-DM proof. DM routing has isolated boundary coverage only.

The final three recordings passed all SUT-scoped event and provider-evidence predicates without unexpected fixture refusals. Earlier build/setup and fixture-diagnostic attempts were preserved separately and are not counted as passing proof.

The operator-provided after-yield screenshot below has been inspected, sanitized, and verified rendering in both the originating chat and this PR. A same-server replay also reached 3/3 complete and its retained card returned HTTP 200 after the test Gateway shut down. The maintainer explicitly waived the missing baseline before-fix screenshot in both destinations; the recorded baseline remains available as behavioral evidence. Hosted CI and the final exact-head ClawSweeper gate still apply before merge.


### Actual Discord client: retained card after parent yield

![Retained Discord checklist with Maple and Cedar activity after parent yield](https://github.com/user-attachments/assets/6670c69a-2de7-4412-8900-75bcd3ef17e6)

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-09-19 22:39:33 +05:30

19 KiB

summary read_when title
Progress drafts: one visible work-in-progress message that updates while an agent runs
Configuring visible progress updates for long-running chat turns
Choosing between partial, block, and progress streaming modes
Explaining how OpenClaw updates one channel message while work is in progress
Troubleshooting progress drafts, standalone progress messages, or finalization fallback
Progress drafts

Progress drafts turn one channel message into a live status line while an agent works, instead of a stack of temporary "still working" replies. Set channels.<channel>.streaming.mode: "progress" and OpenClaw creates the message once real work starts, edits it as the agent reads, plans, calls tools, or waits for approval, then delivers the final answer.

Checking the streaming behavior and running the focused tests.
✅ Read the channel docs
▸ Run the focused tests
▢ Summarize the result

The default draft shows a status headline, authored plan steps, and approval requests. Intermediate tool failures and nonzero command exits stay out of the draft. Set streaming.progress.toolProgress: true to add a rolling tool log, including tool failures, with rows such as 🛠️ Bash: run tests.

Discord defaults preview streaming to `off`; set `streaming.mode: "progress"` to opt in. Telegram defaults to `progress` without additional config. Set `mode: "partial"` on either to stream answer text instead. See [Streaming and chunking](/concepts/streaming#channel-mapping) for the full per-channel default table.

Quick start

{
  channels: {
    discord: {
      streaming: {
        mode: "progress",
      },
    },
  },
}

Defaults from here: a start delay of 1.5 seconds, a quiet status draft while useful work happens, and suppression of the older standalone progress messages for that turn. Raw tool-line drafts use an automatic one-word label; a status headline omits that redundant title unless you configure one explicitly.

This page covers the progress-draft experience and its config knobs. For the full streaming-mode matrix, per-channel runtime notes, and legacy key migration, see Streaming and chunking.

What users see

Part Purpose
Status headline On Discord and Telegram, the model preamble; Discord adds a utility filler.
Label Optional starter/status line such as Working.
Progress lines Plan milestones, enabled commentary/reasoning, and approval requests.
Tool log Optional tool rows using the same icons and detail formatter as /verbose.

The status headline sits above the progress lines. With progress.toolProgress: true, tool rows remain visible underneath it.

For raw tool progress, the label appears once the agent starts meaningful work and stays busy for the initial delay. It sits at the top of the rolling progress-line list, so it scrolls away once enough concrete work lines appear. The implicit label is hidden while a status headline is present unless you configure one explicitly. Plain text-only replies never show a progress draft; a line appears only for real work updates, for example 🛠️ Bash: run tests, 🔎 Web Search: for "discord edit message", or ✍️ Write: to /tmp/file.

Final delivery depends on the channel and transport. OpenClaw either finalizes the draft or sends a separate answer and cleans up or stops updating the draft (see Finalization).

Choose a mode

channels.<channel>.streaming.mode controls the visible in-progress behavior:

Mode Best for What appears in chat
off Quiet channels Only the final answer.
partial Watching answer text appear One draft edited with the latest answer text.
block Larger answer-preview chunks One preview updated or appended in bigger chunks.
progress Tool-heavy or long-running turns One status draft, then the final answer.

Pick progress when users care more about "what is happening" than watching answer text stream token by token; partial when the answer text itself is the progress signal; block for larger preview chunks. On Discord and Telegram, streaming.mode: "block" is still preview streaming, not normal block-reply delivery — use streaming.block.enabled for that.

Configure labels

Progress labels live under channels.<channel>.streaming.progress. The default raw tool-line label is "auto", which uses the plain built-in Working label. A status headline hides that implicit label; set label: "auto" explicitly if you want a label above it too:

Working

Use a fixed label:

{
  channels: {
    discord: {
      streaming: {
        mode: "progress",
        progress: {
          label: "Investigating",
        },
      },
    },
  },
}

Use your own label pool (still picked at random/by seed when label: "auto"):

{
  channels: {
    discord: {
      streaming: {
        mode: "progress",
        progress: {
          label: "auto",
          labels: ["Checking", "Reading", "Testing", "Finishing"],
        },
      },
    },
  },
}

Hide the label and show only progress lines:

{
  channels: {
    discord: {
      streaming: {
        mode: "progress",
        progress: {
          label: false,
        },
      },
    },
  },
}

Control progress lines

Progress lines come from real run events: tool starts, item updates, task plans, approvals, command output, patch summaries, and similar agent activity. progress.toolProgress decides whether ordinary tool calls become rolling rows underneath the status headline. It defaults to false on every channel, which keeps the draft quiet: the headline, enabled commentary and reasoning, plan milestones, and approval requests still appear. Intermediate tool failures and nonzero command exits are hidden along with other tool rows; failures that prevent the turn from completing still appear through normal error delivery. Set it to true for the rolling tool log. Successful background-process polls and internal waits do not add routine rows. Failed calls still follow the selected tool-progress policy; /verbose retains their diagnostic summaries.

Native subagent spawn and activity events follow the same policy. They start the quiet work indicator; with the tool log enabled, lifecycle updates reuse a row for each worker. Messages to workers get separate entries because sending a message does not prove that a worker started running. Delegation prompts are not included in these progress rows.

Tools can also emit typed progress while a single call is still running. That is how a slow fetch or search updates the visible draft before the tool returns its final result. The progress update is a partial tool result with empty model content and explicit public channel metadata:

{
  "content": [],
  "progress": {
    "text": "Fetching page content...",
    "visibility": "channel",
    "privacy": "public",
    "id": "web_fetch:fetching"
  }
}

OpenClaw renders only progress.text in the channel progress UI. The normal tool result still arrives later as content/details and is the only part returned to the model.

When adding progress to a tool, emit a short, generic message and delay it until the operation has been pending long enough to be useful. web_fetch does exactly this with a 5-second delay:

const clearProgressTimer = scheduleToolProgress(
  onUpdate,
  { text: "Fetching page content...", id: "web_fetch:fetching" },
  5_000,
  { signal },
);

try {
  return await runToolWork();
} finally {
  clearProgressTimer();
}

Fast calls show no progress line; long calls show one while still pending; canceled calls clear the timer before stale progress can appear. Progress text is a public UI side channel, so it must never include secrets, raw arguments, fetched content, command output, or page text.

Detail mode

OpenClaw uses the same formatter for progress drafts and /verbose:

{
  agents: {
    defaults: {
      toolProgressDetail: "explain", // explain | raw
    },
  },
}

"explain" is the default and keeps drafts stable with concise labels. "raw" appends underlying tool detail when available. Command text also requires the explicit streaming.progress.commandText: "raw" opt-in below. With that opt-in, a node --check /tmp/app.js call renders differently by mode:

Mode Progress line
explain 🛠️ check js syntax for /tmp/app.js
raw 🛠️ check js syntax for /tmp/app.js · node --check /tmp/app.js

Command/exec text

streaming.progress.commandText (default "status") controls how much command detail shows next to exec/bash progress lines, independent of the detail mode above. Set it to "raw" to opt into command text; keep "status" to show only the tool-progress status:

{
  channels: {
    discord: {
      streaming: {
        mode: "progress",
        progress: {
          toolProgress: true,
          commandText: "raw",
        },
      },
    },
  },
}

Commentary lane

streaming.progress.commentary (default false) interleaves the model's pre-tool commentary/preamble narration (💬, for example "I'll check... then ...") with tool lines in the draft. See Streaming and chunking for the shared config shape across channels.

With the commentary lane enabled, preambles render only as those interleaved 💬 lines; the status headline below stays out of the way so the lane keeps its documented shape.

Status headline

On Discord and Telegram in progress mode, the model's typed pre-tool preamble becomes the draft's status headline whenever it is available. Other progress-mode channels keep their existing status behavior. The headline is on by default and does not bypass the normal activity gate for short turns; enabling streaming.progress.commentary hands preambles to the interleaved commentary lane instead.

On Discord, when a utility model resolves for the agent — an explicit utilityModel, or the primary provider's declared small-model default (OpenAI → gpt-5.6-luna, Anthropic → claude-haiku-4-5) — it supplies a short plain-language filler when the model emits no preamble or has been quiet for about 20 seconds (Telegram's headline is preamble-only today):

Updating the default model in your config, then restarting the gateway to pick
it up. One agent listing call failed and is being retried.

Utility narration is on by default (streaming.progress.narration, default true) and never falls back to the primary model: it runs only with an explicit utilityModel or a provider-declared default for the agent's primary provider. Set utilityModel: "" to disable utility routing entirely. When progress.toolProgress is enabled, tool lines keep accumulating underneath. Draft edits still wait for the normal activity gate and an actual text change, which avoids flashes on fast turns and reduces edit churn in busy channels. Set narration: false to disable only the utility-model filler; model preamble headlines remain enabled:

{
  channels: {
    discord: {
      streaming: {
        mode: "progress",
        progress: {
          narration: false,
        },
      },
    },
  },
}

Narration input is bounded and redacted: the utility model receives the inbound request text plus the same compact, redacted tool summaries the draft would render — never raw command output or tool results. With commandText: "status", narration input also omits exec/bash command text, matching what the draft shows.

Narration belongs to the current turn. Ending or replacing that turn cancels its pending utility-model request and prevents late results from updating the draft. Tool activity that accumulates during a narration request is reconsidered when that request finishes, so an eligible status update needs no additional event.

Line limits

Limit how many lines stay visible (default 8):

{
  channels: {
    discord: {
      streaming: {
        mode: "progress",
        progress: {
          maxLines: 4,
        },
      },
    },
  },
}

With toolProgress: true, command exit rows use ordinary tool-log capacity, including exits with a code other than 0. Older exits scroll out as newer activity arrives and do not reduce the plan's line budget. Approval requests and explicit failed, error, or blocked states still take priority. With the tool log hidden, tool failures and nonzero exits are hidden too; approval requests remain visible.

Progress lines are compacted automatically to reduce chat-bubble reflow while the draft is edited, and OpenClaw truncates long lines so repeated draft edits do not wrap differently on every update. The default per-line budget is 120 characters; prose cuts at a word boundary, while long details such as paths or raw commands are shortened with a middle ellipsis so the suffix stays visible.

Tune the per-line budget:

{
  channels: {
    discord: {
      streaming: {
        mode: "progress",
        progress: {
          maxLineChars: 160,
        },
      },
    },
  },
}

Show the tool log

Add the rolling tool log to the single progress draft:

{
  channels: {
    discord: {
      streaming: {
        mode: "progress",
        progress: {
          toolProgress: true,
        },
      },
    },
  },
}

With the default toolProgress: false, OpenClaw still suppresses the older standalone tool-progress messages for that turn; the draft shows the headline, authored text, plan milestones, and approval requests only. Tool diagnostics remain available in the session transcript.

Channel behavior

Channel Progress transport Notes
Discord Send one message, then edit it. progress is explicit opt-in; the status draft is deleted after the final answer lands.
Matrix Send one event, then edit it. Account-level streaming config controls account-level drafts.
Microsoft Teams Native Teams stream in personal chats. streaming.mode: "block" maps to Teams block delivery instead.
Slack Native stream or editable draft post. Card style is the default; progress.style: "compact" uses a temporary text draft, deleted after the final answer is delivered.
Telegram Send one message, then edit it. If a message lands between the progress draft and the answer, the draft reposts below it (post-new-then-delete-old) instead of scroll-jumping the client.
Mattermost Editable draft post. block mode rotates between completed text and tool-activity posts; other modes fold tool activity into the same draft-style post.

Channels without safe edit support fall back to typing indicators or final-only delivery. See Streaming and chunking for the full runtime-behavior breakdown per channel.

Finalization

When the final answer is ready, OpenClaw tries to keep the chat clean:

  • A Discord or Telegram progress card handed off to accepted subagents stays visible across parent yield. Core updates that same card while delegated work continues; the eventual final answer is separate. See Subagent yield handoff.

  • Otherwise, in progress mode on Discord, the final answer is sent as a fresh message and the status draft is deleted once that answer is delivered. Busy channels keep no orphaned tool log above the reply; error finals keep the draft as the visible record of the failed turn.

  • If the draft can safely become the final answer (partial/block modes), OpenClaw edits it in place.

  • Slack's compact progress style posts the final answer as a new message and deletes its temporary drafts after confirmed delivery. Failed delivery keeps the draft visible.

  • If the channel uses native progress streaming, OpenClaw finalizes that stream when the native transport accepts the final text.

  • Otherwise (media, an approval prompt, an explicit reply target, too many chunks, or a failed edit/send) OpenClaw sends the final answer through the normal channel delivery path instead of overwriting the draft.

The fallback is intentional: sending a fresh final answer beats losing text, mis-threading a reply, or overwriting a draft with a payload the channel cannot represent safely.

Troubleshooting

I only see the final answer.

Check that channels.<channel>.streaming.mode is progress for the account or channel that handled the message. Some group or quote-reply paths disable draft previews for a turn when the channel cannot safely edit the right message.

I see the label but no tool lines.

Check streaming.progress.toolProgress. It defaults to false, which keeps the single draft but hides the rolling tool rows; set it to true for the full tool log.

I see a fresh final message instead of an edited draft.

That is the safety fallback described in Finalization. It can happen for media replies, long answers, explicit reply targets, old Telegram drafts, missing Slack thread targets, deleted preview messages, or failed native stream finalization.

I still see standalone progress messages.

Progress mode suppresses default standalone tool-progress messages whenever a draft is active. If standalone messages still appear, confirm the turn is actually using progress mode and not streaming.mode: "off" or a channel path that cannot create a draft for that message.

Teams behaves differently from Discord or Telegram.

Microsoft Teams uses a native stream in personal chats instead of the generic send-and-edit preview transport, and maps streaming.mode: "block" to Teams block delivery because it has no draft-preview block mode like Discord and Telegram.