Markdown Formatting
Markdown formatting pipeline for outbound channels.
OpenClaw formats outbound Markdown by converting it to a shared intermediate
representation (IR) before rendering channel-specific output. The IR preserves
source text intact while carrying style/link spans, so chunking and rendering can
stay consistent across channels.
Goals
- Consistency: One parse step, multiple renderers.
- Safe chunking: Split text before rendering, so inline formatting never
breaks across chunks.
- Channel matching: Map the same IR to Slack mrkdwn, Telegram HTML, and Signal
style spans without re-parsing Markdown.
Pipeline
1. Parse Markdown -> IR
- IR is plain text plus style spans (bold/italic/strikethrough/code/spoiler) and link spans.
- Offsets are UTF-16 code units, so Signal style spans match their API.
- Tables are only parsed when the channel opts into table conversion.
2. Chunk IR (format-first)
- Chunk IR text before rendering.
- Inline formatting never splits across chunk boundaries; spans are sliced per chunk.
3. Per-channel rendering
- ''Slack:'' mrkdwn tokens (bold/italic/strike/code), links as ''<url|label>''.
- ''Telegram:'' HTML tags (''<b>'', ''<i>'', ''<s>'', ''<code>'', ''<pre><code>'', ''<a href>'').
- ''Signal:'' Plain text + ''text-style'' spans; links become ''label (url)'' when labels differ.
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" } ] }Where it's used
- Slack, Telegram, and Signal outbound adapters render from IR.
- Other channels (WhatsApp, iMessage, MS Teams, Discord) still use plain text or
their own formatting rules, with Markdown table conversion applied before
chunking when enabled.
Table handling
Markdown tables aren't consistently supported across chat clients. Use
''markdown.tables'' to control per-channel (and per-account) conversion.
- ''code'': Render tables as code blocks (default for most channels).
- ''bullets'': Convert each row to bullet points (default for Signal + WhatsApp).
- ''off'': Disable table parsing and conversion; raw table text passes through.
Config keys:
channels: discord: markdown: tables: code accounts: work: markdown: tables: off
Chunking rules
- Chunk limits come from channel adapters/config and apply to IR text.
- Code fences are kept as single chunks with trailing newlines, so channels
render them correctly.
- List prefixes and blockquote prefixes are part of IR text, so chunking
doesn't split mid-prefix.
- Inline styles (bold/italic/strikethrough/inline code/spoiler) never split
across chunks; renderers reopen styles within each chunk.
If you need more info on cross-channel chunking behavior, see
Link policy
- ''Slack:'' ''[label](url)'' -> ''<url|label>''; bare URLs stay bare. Autolinks
are disabled during parsing to avoid double-linking.
- ''Telegram:'' ''[label](url)'' -> ''<a href="url">label</a>'' (HTML parse mode).
- ''Signal:'' ''[label](url)'' -> ''label (url)'' unless label matches URL.
Spoilers
Spoiler tags (''||spoiler||'') are only parsed for Signal, where they map to
Spoiler style ranges. Other channels treat them as plain text.
How to add or update a channel formatter
1. ''Parse once:'' Use shared ''markdownToIR(...)'' helper with
channel-appropriate options (autolinks, heading styles, blockquote prefixes).
2. ''Render:'' Use ''renderMarkdownWithMarkers(...)'' and an implementing renderer
style token map (or Signal style ranges).
3. ''Chunk:'' Call ''chunkMarkdownIR(...)'' before rendering; render each chunk.
4. Wire adapter: Update channel outbound adapter to use new chunker
and renderer.
5. Test: Add or update formatting tests and outbound delivery tests (if
channel uses chunking).
Common pitfalls
- Angle bracket markers (''<@U123>'', ''<#C123>'', ''<https://...>'') must be
preserved; safely escape raw HTML.
- Telegram HTML needs text escaped outside of tags to avoid markup corruption.
- Signal style ranges depend on UTF-16 offsets; don't use code point offsets.
- Preserve trailing newlines for fenced code blocks so closing tags land on
their own lines.