Skip to main content

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.2.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 when the plain text is at most 150 characters, otherwise the bold form used for ###
### 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). A fence info string such as python is copied to language
--- 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>.

Section mrkdwn escapes &, <, and > as &amp;, &lt;, and &gt;, except for tokens this library emits (<url|label>, <@U…>, <#C…>, <!…>). rich_text text is left as written, because Slack does not parse mrkdwn there. A header block is plain text and is not escaped. A heading longer than 150 characters is emitted as a bold section instead, and that text is escaped.

Slack-specific tokens are recognized in the text:

  • <@U…>, <#C…>, <!subteam^S…>, <!subteam^T…>
  • <!here>, <!channel>, <!everyone>
  • <!date^timestamp^format|fallback>
  • :emoji: shortcodes
  • #rrggbb color 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 U or W
  • 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 &amp; 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. Tags inside fenced code are left alone.

Registered names are lenient, because registering a handler means you expect that tag. A matching close tag still wins, so nesting works. If the start tag never closes, the body runs until the next registered start tag, or to the end of the input, and it does not swallow a later element. A stray close tag for a registered name is dropped. Unregistered tags, and comparisons such as 2 < 5, stay as Markdown.

Slack's container block is the usual wrapper. container_block builds one. Slack allows at most 10 children and a plain-text title of at most 150 characters; split_blocks enforces both. A data_table is not a legal container child, so container_block rewrites those children to the legacy table block.

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.

Arbitrary Slack blocks

An agent can emit Block Kit JSON that this library does not know how to build. Register slack_blocks_handler for <slack-blocks>. The body is a JSON list of blocks, one block object, or {"text": "...", "blocks": [...]}. A fenced json block around the JSON is fine. The blocks are inserted as written, so Slack mrkdwn inside them is not escaped again.

text is not sent to Slack. slack_blocks_to_text swaps each tag for that Markdown so a web UI can render it. If text is missing, the blocks are rendered back to Markdown. If the body is not JSON, both paths treat it as Markdown.

from markdown_to_slack_blocks import (
    markdown_to_blocks,
    slack_blocks_handler,
    slack_blocks_to_text,
)

web = slack_blocks_to_text(agent_markdown)
blocks = markdown_to_blocks(agent_markdown, {
    "xml_tag_handlers": {"slack-blocks": slack_blocks_handler},
})
<slack-blocks>
{"text": "Deployed **api** to prod.", "blocks": [
  {"type": "section", "text": {"type": "mrkdwn", "text": "Deployed *api* to prod."}},
  {"type": "actions", "elements": [
    {"type": "button", "text": {"type": "plain_text", "text": "Rollback"}, "action_id": "rollback"}
  ]}
]}
</slack-blocks>

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

An empty cell is a single space. Slack rejects raw_text whose text is empty.

A data_table must have at least 2 rows including the header, at most 20 columns, at most 201 rows, and at most 20,000 characters of cell text. More than 20 columns, or fewer than 2 rows, becomes one legacy table. Extra rows, or a character count past 20,000, are split into further data_table blocks that repeat the header. One header plus one row that is already over 20,000 characters becomes a legacy table by itself.

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. The fence language is kept on each piece. Section text is chunked at 3,000 characters. A header longer than 150 characters is rewritten as bold sections (the same form as a ### heading) instead of being split as a header block.

A container that has more than 10 children, or whose JSON is over the size limit, becomes several containers with the same settings. Later pieces use the title {title} (continued), truncated so a plain-text title stays within 150 characters. Children are normalised first: sections and headers are chunked, nested containers are split, and any data_table is rewritten to table.

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; a container contributes its title only
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.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for markdown-to-slack-blocks 1.2.0
File Size Uploaded
markdown_to_slack_blocks-1.2.0.tar.gz 38.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for markdown-to-slack-blocks 1.2.0
File Interpreter ABI Platform
markdown_to_slack_blocks-1.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 66.8 kB

Release files / markdown_to_slack_blocks-1.2.0.tar.gz

Download URL markdown_to_slack_blocks-1.2.0.tar.gz
Size 38.3 kB
Tags Source
SHA-256 checksum
How to use checksums
2b8b49ac9affd4d873c7b9c362798b54902cff305361ee0281a580f288482e59
BLAKE2b-256 checksum
How to use checksums
14025706b4642359a6c2eee047b1efc779f2a764d5610b251691de30d975a527
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 25, 2026.

Transparency log

Release files / markdown_to_slack_blocks-1.2.0-py3-none-any.whl

Download URL markdown_to_slack_blocks-1.2.0-py3-none-any.whl
Size 28.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
34c869845c02eb3c3a61238b9bb0328738817b94615e76580b83400fdded2462
BLAKE2b-256 checksum
How to use checksums
c5d80a169170316735753ce2d7798e0827d97c0cab5a4455b2e5c365ea21bc2a
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 25, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

1.2.0 This release

2 release files

1.1.0

2 release files

1.0.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page