docs/concepts/markdown-formatting.md
OpenClaw converts outbound Markdown into a shared intermediate representation (IR) before rendering channel-specific output. The IR keeps plain text plus style/link spans, so one parse step feeds every channel and chunking never splits formatting mid-span.
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.chunkMarkdownIR / renderMarkdownIRChunksWithinLimit)
renderMarkdownWithMarkers) - a style-marker map
turns spans into the channel's native markup.| Channel | Renderer | Notes |
|---|---|---|
| Slack | mrkdwn tokens (*bold*, _italic_, `code`, code fences) | Links become <url|label>; autolink disabled during parse to avoid double-linking |
| Telegram | HTML tags (<b>, <i>, <s>, <code>, <pre><code>, <a href>, <tg-spoiler>) | Also supports rich-message tables and headings (<h1>-<h6>) when richMessages is on |
| Signal | plain text + text-style ranges | Links render as label (url) when the label differs from the URL |
| Discord, WhatsApp, iMessage, Microsoft Teams, and other channels | plain text | No IR-based styling; Markdown table conversion still runs via convertMarkdownTables |
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" }]
}
markdown.tables controls how a channel converts Markdown tables, per
channel and optionally per account:
| Mode | Behavior |
|---|---|
code | Render as an aligned ASCII table inside a code block (fallback default) |
bullets | Convert each row into label: value bullet points |
block | Keep native tables where the transport supports them; falls back to code otherwise |
off | Disable 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
See Streaming and chunking for chunk-boundary and delivery behavior across channels.
[label](url) -> <url|label>; bare URLs stay bare.[label](url) -> <a href="url">label</a> (HTML parse mode).[label](url) -> label (url) unless the label already
matches the URL.Spoiler markers (||spoiler||) are parsed for Signal (mapped to SPOILER
style ranges) and Telegram (mapped to <tg-spoiler>). Other channels treat
||...|| as plain text.
markdownToIR(...), passing channel-appropriate
options (autolink, headingStyle, blockquotePrefix, tableMode).renderMarkdownWithMarkers(...) and a style-marker map (or
custom style-range logic for transports like Signal).chunkMarkdownIR(...) or
renderMarkdownIRChunksWithinLimit(...) before rendering each chunk.<@U123>, <#C123>, <https://...>) must
survive escaping; raw HTML still needs to be escaped safely.