Markdown Formatting Pipeline for Outbound Channels

Learn how OpenClaw converts Markdown into a shared intermediate representation, enabling consistent rendering across channels. This guide is for developers integrating channel-specific output.

Read this when

  • You are changing markdown formatting or chunking for outbound channels
  • You are adding a new channel formatter or style mapping
  • You are debugging formatting regressions across channels

OpenClaw takes outbound Markdown and first converts it into a shared intermediate representation (IR) before any channel-specific output gets rendered. Within the IR, plain text is preserved alongside style and link spans, which means a single parsing pass can feed every channel, and chunking never cuts formatting in the middle of a span.

Pipeline

  1. Parse Markdown into IR (markdownToIR) - plain text plus style spans (bold, italic, strikethrough, code, code block, spoiler, blockquote, heading 1-6) and link spans. Offsets are UTF-16 code units so Signal style ranges align with its API directly. Tables parse only when the channel opts into a table mode.
  2. Chunk the IR (chunkMarkdownIR / renderMarkdownIRChunksWithinLimit)
    • splitting happens on IR text before rendering, so inline styles and links are sliced per chunk instead of breaking across a boundary.
  3. Render per channel (renderMarkdownWithMarkers) - a style-marker map turns spans into the channel's native markup.
ChannelRendererNotes
Slackmrkdwn tokens (*bold*, _italic_, `code`, code fences)Links become <url|label>; autolink disabled during parse to avoid double-linking
TelegramHTML tags (<b>, <i>, <s>, <code>, <pre><code>, <a href>, <tg-spoiler>)Also supports rich-message tables and headings (<h1>-<h6>) when richMessages is on
Signalplain text + text-style rangesLinks render as label (url) when the label differs from the URL
Discord, WhatsApp, iMessage, Microsoft Teams, and other channelsplain textNo IR-based styling; Markdown table conversion still runs via convertMarkdownTables

IR example

Input Markdown:

Hello **world** - see [docs](https://docs.openclaw.ai/).

IR (schematic):

{
  "text": "Hello world - see docs.",
  "styles": [{ "start": 6, "end": 11, "style": "bold" }],
  "links": [{ "start": 19, "end": 23, "href": "https://docs.openclaw.ai" }]
}

Table handling

markdown.tables determines how a channel handles Markdown tables, configurable per channel and optionally per account:

ModeBehavior
codeRender as an aligned ASCII table inside a code block (fallback default)
bulletsConvert each row into label: value bullet points
blockKeep native tables where the transport supports them; falls back to code otherwise
offDisable table parsing; raw table text passes through unchanged

Per-channel plugin defaults: Signal, WhatsApp, and Matrix default to bullets; Mattermost defaults to off; Telegram defaults to block (which resolves to code unless the account has richMessages enabled). Any channel without an explicit plugin default falls back to code.

channels:
  discord:
    markdown:
      tables: code
    accounts:
      work:
        markdown:
          tables: off

Chunking rules

  • Chunk limits come from channel adapters/config and apply to IR text, not rendered output.
  • Fenced code blocks are kept as one block with a trailing newline so channels render the closing fence correctly.
  • List and blockquote prefixes are part of the IR text, so chunking never splits mid-prefix.
  • Inline styles never split across chunks; the renderer reopens an open style at the start of the next chunk.

See Streaming and chunking for chunk-boundary and delivery behavior across channels.

  • Slack: [label](url) -> <url|label>; bare URLs stay bare.
  • Telegram: [label](url) -> <a href="url">label</a> (HTML parse mode).
  • Signal: [label](url) -> label (url) unless the label already matches the URL.

Spoilers

Spoiler markers (||spoiler||) are parsed for Signal (mapped to SPOILER style ranges) and Telegram (mapped to <tg-spoiler>). Other channels treat ||...|| as plain text.

Collapsible details

The Control UI and Telegram accounts with richMessages: true render <details><summary>Label</summary> disclosures as native collapsible sections. OpenClaw tells the model about this option only when the current reply surface supports it. Other channels, including Telegram accounts without rich messages, flatten each disclosure to **Summary** followed by the visible body so no content is hidden or lost.

Adding or updating a channel formatter

  1. Single-pass parsing via markdownToIR(...), supplying transport-specific parameters (autolink, headingStyle, blockquotePrefix, tableMode).
  2. Output generation through renderMarkdownWithMarkers(...) paired with a style-marker map (or bespoke style-range handling for transports such as Signal).
  3. Segmentation using chunkMarkdownIR(...) or renderMarkdownIRChunksWithinLimit(...) prior to rendering each segment.
  4. Adapter integration so the outbound send path invokes the new chunker and renderer.
  5. Verification with format checks, plus an outbound delivery test when the channel segments.

Common gotchas

  • Slack's angle-bracket tokens (<@U123>, <#C123>, <https://...>) must remain intact after escaping; raw HTML still requires safe escaping.
  • Telegram's HTML needs text outside tags escaped to prevent malformed markup.
  • Signal style ranges rely on UTF-16 offsets, not code-point offsets.
  • Keep trailing newlines on fenced code blocks so the closing marker sits on its own line.
  • Streaming and chunking, Outbound streaming behavior, segment limits, and per-channel delivery.

  • System prompt, The model's pre-conversation context, including injected workspace files.

807 words · updated Jul 31, 2026