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
- 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. - 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.
- Render per channel (
renderMarkdownWithMarkers) - a style-marker map converts spans into the channel's native markup.
| Channel | Renderer | Notes |
|---|---|---|
| Slack | mrkdwn tokens (*bold*, _italic_, `code`, code fences) | Links become <url|label>; autolink is disabled during parsing to prevent 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 enabled |
| 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 |
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:
| 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
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.
Link policy
- 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
- Parse once using
markdownToIR(...)with transport-appropriate flags (autolink,headingStyle,blockquotePrefix,tableMode). - Render by calling
renderMarkdownWithMarkers(...)along with a style token map (or custom style range logic for transports like Signal). - Chunk via
chunkMarkdownIR(...)orrenderMarkdownIRChunksWithinLimit(...)before rendering each segment. - Wire the adapter so the outbound send path invokes the new chunker and renderer.
- 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.
Related
- 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.