Quick Use
pip install slack-bolt- Drop
md_to_blocks()into your bot module - Pipe LLM markdown straight into
chat.postMessage(blocks=md_to_blocks(text))
Intro
This is a small Python helper that takes plain markdown from an LLM and produces valid Slack Block Kit JSON — header, section, divider, fields, buttons, image — automatically chunking text past Slack's 3001-char per-block limit. Slack messages without Block Kit look terrible; messages with Block Kit require fragile hand-built JSON. This bridges them. Best for: AI bots posting summaries, alerts, daily digests, and reports to Slack channels. Works with: Slack Bolt SDK (Python/JS), Slack chat.postMessage REST. Setup time: 5 minutes.
Helper code
from typing import Iterable
def md_to_blocks(text: str) -> list[dict]:
blocks = []
for raw in text.split("\n\n"):
chunk = raw.strip()
if not chunk:
continue
if chunk.startswith("# "):
blocks.append({"type": "header", "text": {"type": "plain_text", "text": chunk[2:].strip()[:150]}})
elif chunk == "---":
blocks.append({"type": "divider"})
else:
for piece in _split_3000(chunk):
blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": piece}})
return blocks
def _split_3000(s: str, limit: int = 2900) -> Iterable[str]:
while len(s) > limit:
cut = s.rfind("\n", 0, limit)
if cut == -1:
cut = s.rfind(" ", 0, limit) or limit
yield s[:cut]
s = s[cut:].lstrip()
if s:
yield s
def add_actions(blocks: list[dict], buttons: list[tuple[str, str, str]]) -> list[dict]:
if not buttons:
return blocks
blocks.append({
"type": "actions",
"elements": [
{"type": "button", "text": {"type": "plain_text", "text": label},
"action_id": action_id, "url": url}
for label, action_id, url in buttons
],
})
return blocksUsage with Bolt
from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler
import os
app = App(token=os.environ["SLACK_BOT_TOKEN"])
llm_text = '''# Daily AI Digest
*5 new MCP servers* shipped on TokRepo today.
- Stripe MCP — payments
- Datadog MCP — observability
- Linear MCP — issue tracker
---
Top reaction: stripe-mcp got 47 stars in 6 hours.'''
blocks = md_to_blocks(llm_text)
blocks = add_actions(blocks, [("Open TokRepo", "open_tokrepo", "https://tokrepo.com/explore")])
app.client.chat_postMessage(channel="#ai-digest", blocks=blocks, text="Daily AI Digest")Slack Block Kit limits (cheat sheet)
| Item | Limit |
|---|---|
| Blocks per message | 50 |
| Section text | 3,000 chars |
| Header text | 150 chars |
| Action elements per actions block | 25 |
| Total message size | 40 KB |
FAQ
Q: Why mrkdwn instead of markdown?
A: Slack's mrkdwn is a subset — *bold* (one asterisk, not two), _italic_, <url|label> for links, no headings, no tables. The helper outputs mrkdwn-flavored text. Hand-converting LLM markdown is the part this saves.
Q: Does the LLM need to know about Block Kit? A: No — that's the point. Prompt it for normal markdown. The helper handles the conversion. Hand the LLM Block Kit JSON in the prompt and outputs become brittle and verbose.
Q: What if the message is over 50 blocks?
A: Slack rejects with invalid_blocks error. Send as a thread — first message gets the first 50 blocks, then chat.postMessage with thread_ts for follow-ups. Or split into multiple top-level messages with day/section headers.
Source & Thanks
Pattern compiled from Slack Block Kit + Bolt SDK.
Bolt MIT-licensed, helper Apache-2.0.