Markdown Formatting Pipeline for Outbound Channels

This page explains how OpenClaw transforms outbound Markdown into a shared intermediate representation (IR) for channel-specific output. Developers need to understand this pipeline to ensure formatting consistency and avoid broken spans across channels.

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 transforms outbound Markdown into a shared intermediate representation (IR) before producing channel-specific output. The IR preserves plain text along with style and link spans, allowing a single parse step to serve every channel and ensuring chunking never breaks formatting within 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 use UTF-16 code units so Signal style ranges align directly with its API. Tables are parsed only when the channel opts into a table mode.
  2. Chunk the IR (chunkMarkdownIR / renderMarkdownIRChunksWithinLimit)
    • Splitting occurs on IR text before rendering, so inline styles and links are sliced per chunk rather than crossing a boundary.
  3. Render per channel (renderMarkdownWithMarkers) - a style-marker map converts spans into the channel's native markup.
ChannelRendererNotes
Slackmrkdwn tokens (*bold*, _italic_, `code`, code fences)Links become <url|label>; autolink is disabled during parsing to prevent 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 enabled
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, 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 or configuration and apply to IR text, not rendered output.
  • Fenced code blocks remain 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.

Adding or updating a channel formatter

  1. Parse once using markdownToIR(...) with transport-appropriate flags (autolink, headingStyle, blockquotePrefix, tableMode).
  2. Render by calling renderMarkdownWithMarkers(...) along with a style token map (or custom style range logic for transports like Signal).
  3. Chunk via chunkMarkdownIR(...) or renderMarkdownIRChunksWithinLimit(...) before rendering each segment.
  4. Wire the adapter so the outbound send path invokes the new chunker and renderer.
  5. Test using format tests and, when the channel splits messages, an outbound delivery test.

Common gotchas

  • Slack angle bracket tokens (<@U123>, <#C123>, <https://...>) need to stay escaped; raw HTML still requires safe escaping.
  • For Telegram HTML, text outside tags must be escaped to prevent broken markup.
  • Signal style ranges rely on UTF-16 offsets, not code point offsets.
  • Keep trailing newlines on fenced code blocks so the closing marker appears on its own line.
  • Streaming and chunking, How outbound streaming behaves, where chunk boundaries fall, and channel specific delivery.
  • System prompt, The content the model receives before the conversation starts, including any injected workspace files.