markdown-to-slack-blocks
Convert Markdown into Slack Block Kit JSON, and render blocks back to Markdown or plain text.
This is a Python port of udivankin/markdown-to-slack-blocks v1.6.1 (MIT), released here as 1.0.0. It is aimed at the same job: take Markdown from people or from an LLM and post it to Slack without losing headings, lists, code, tables, or mentions.
pip install markdown-to-slack-blocks
from markdown_to_slack_blocks import markdown_to_blocks
blocks = markdown_to_blocks("""
# Hello World
This is a **bold** statement.
""")
markdown_to_blocks is also available as markdownToBlocks if you are moving a call site over from the JavaScript package. The same aliases exist for splitBlocks, splitBlocksWithText, blocksToMarkdown, and blocksToPlainText.
What it emits
| Markdown | Block |
|---|---|
| Paragraphs | section (mrkdwn) by default, or rich_text |
# / ## |
header |
### and below |
bold section, or a bold rich_text section |
| Lists, quotes, fenced code | rich_text (rich_text_list, rich_text_quote, rich_text_preformatted) |
--- |
divider |
| A paragraph that is only an image | image |
| GFM tables | data_table (or legacy table) |
Inline styles become Slack mrkdwn (*bold*, _italic_, ~strike~, `code`) inside sections, and rich_text style objects otherwise. Links become <url|label>.
Slack-specific tokens are recognized in the text:
<@U…>,<#C…>,<!subteam^S…>,<!subteam^T…><!here>,<!channel>,<!everyone><!date^timestamp^format|fallback>:emoji:shortcodes#rrggbbcolor swatches when color detection is on
Options
blocks = markdown_to_blocks(markdown, {
"mentions": {
"users": {"username": "U123456"},
"channels": {"general": "C123456"},
"user_groups": {"engineers": "S123456"}, # or "userGroups"
"teams": {"myteam": "T123456"},
},
"detect_colors": True, # detectColors
"prefer_section_blocks": True, # preferSectionBlocks, default True
"table_block_type": "data_table", # "table" for the legacy block
"table_caption": "Data table", # "" omits the caption
})
Mention IDs are checked before conversion:
- users start with
UorW - channels start with
C - user groups start with
S - teams start with
T
and the rest of the ID is uppercase alphanumeric.
XML tag handlers
Tags the library does not know, such as <sources> or <detailed>, are not Slack blocks. Pass xml_tag_handlers (xmlTagHandlers) to turn specific elements into whatever blocks you want. The handler is called with an XmlTagContext: the element name, its attributes, and the inner Markdown. convert parses that inner Markdown with the same options, so nested elements work too.
The tags are parsed with Python's expat XML parser, not a regular expression. Names are case-sensitive. Attributes follow XML rules: values are quoted, and entities such as & are decoded. The text inside the element is Markdown, so it is not parsed as XML. a < b and a raw & in the body are kept as written. A start tag that never closes, and a close tag that was never opened, stay as Markdown and do not swallow a later well-formed element. Tags inside fenced code are left alone too.
Slack's container block is the usual wrapper. container_block builds one. child_blocks holds at most 10 blocks, and the plain-text title is at most 150 characters.
from markdown_to_slack_blocks import container_block, markdown_to_blocks
def sources(tag):
children = tag.convert(tag.body)
if not children:
return []
return container_block(tag.attrs.get("title") or "Sources", children, collapsible=True)
def detailed(tag):
return container_block(
"Details",
tag.convert(tag.body),
collapsible=True,
default_collapsed=True,
)
blocks = markdown_to_blocks(agent_markdown, {
"xml_tag_handlers": {"sources": sources, "detailed": detailed},
})
Answer text.
<sources title="References">
- [Runbook](https://example.com/runbook)
</sources>
<detailed>
## Investigation
The check failed because **disk** was full.
</detailed>
Return one block, a list of blocks, or an empty list to drop the element. Return None to leave that occurrence as normal Markdown.
register_xml_tag_handler("sources", sources) installs a process-wide default. An xml_tag_handlers entry overrides it, and setting the name to None there turns the global handler off for that call. clear_xml_tag_handlers() removes the defaults.
Tables
Cells are typed from their content:
| Cell | Slack cell |
|---|---|
| Plain text | raw_text |
A plain number (10, -3.5) |
raw_number |
| Styles, links, mentions, emoji | rich_text |
Large messages
Slack rejects messages that are too big. split_blocks cuts on block boundaries, then inside rich_text, then by line inside code blocks. Section and header text is chunked at 3,000 characters first.
from markdown_to_slack_blocks import markdown_to_blocks, split_blocks_with_text
for batch in split_blocks_with_text(markdown_to_blocks(very_long_markdown)):
client.chat_postMessage(channel=channel, text=batch["text"], blocks=batch["blocks"])
Limits default to 40 blocks and 12,000 JSON characters (max_blocks / maxBlocks, max_characters / maxCharacters).
Back to Markdown or plain text
from markdown_to_slack_blocks import blocks_to_markdown, blocks_to_plain_text
text = blocks_to_plain_text(blocks) # chat.postMessage fallback
markdown = blocks_to_markdown(blocks, {
"mentions": {
"users": {"U123456": "username"},
"channels": {"C123456": "general"},
"user_groups": {"S123456": "engineers"},
"teams": {"T123456": "myteam"},
}
})
The Markdown is canonical rather than byte-for-byte identical to the source. Blocks this library produced round-trip cleanly.
Development
pip install -e ".[dev]"
pytest
The tests include the upstream fixture corpus (tests/fixtures) and check both directions against it.
License
MIT. The original library is copyright https://github.com/udivankin. This Python port is copyright Nikita Nefedov. See LICENSE.
Release files for markdown-to-slack-blocks 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| markdown_to_slack_blocks-1.0.0.tar.gz | 29.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| markdown_to_slack_blocks-1.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 53.1 kB
Release files / markdown_to_slack_blocks-1.0.0.tar.gz
| Download URL | markdown_to_slack_blocks-1.0.0.tar.gz |
|---|---|
| Size | 29.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
2e00e568d6f623d8dea1e4c6acba7eeda10e2ca77735a9e4dbf90bc4314e5033
|
|
BLAKE2b-256 checksum How to use checksums |
eb36bbbc9698a1c6c282a7b3e0972a5f0cc5e341107600902617246b159cab4b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 24, 2026.
Transparency logRelease files / markdown_to_slack_blocks-1.0.0-py3-none-any.whl
| Download URL | markdown_to_slack_blocks-1.0.0-py3-none-any.whl |
|---|---|
| Size | 23.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
6e8826afda80f16330b0a3d279637906f782460befccee2dac585a52db91e187
|
|
BLAKE2b-256 checksum How to use checksums |
c46042639ca99894892a9f97777299efc9e7bb30075475e5de00bce0a6769c12
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 24, 2026.
Transparency log