Long content and splitting
A Slack message holds at most 50 blocks, and several block types carry
character budgets on top (12,000 cumulative markdown characters, 10,000
table-cell characters, …). Agents regularly produce answers that exceed all of
that. You choose what happens: truncate predictably, or split into multiple
messages.
Default: degrade
convert() always produces a single message. When the 50-block cap is hit,
later constructs are omitted whole, each with a degradation. You never get half
a paragraph or half a code block:
import { convert } from "slackmark";
const { blocks, text, degradations } = await convert(veryLongMarkdown);
// blocks.length <= 50; overflowing nodes omit with records, e.g.:
// { nodeType: "heading", from: "adapter", to: "empty",
// reason: "Message budget exhausted (blocks/chars/charts); content omitted",
// path: "root[50]" }Oversized single constructs are cut within the block instead, earlier in
conversion. A 300-data-row table emits a data_table with the first 200 rows,
and markdown-strategy output is chunked at the 12,000-character boundary, each
with its own degradation. Footnotes keep reserved slots at the end of the
message (the assembler may clip body blocks to make room, with a message
degradation on path: "assemble").
Split into multiple messages
convertToMessages() with overflow: "split" breaks the document into as many
messages as needed, splitting between blocks (never inside a table or code
block):
export async function postLongAnswer(
markdown: string,
botToken: string,
channel: string,
): Promise<PreparedBatch> {
const { messages, receipts } = await convertToMessages(markdown, { overflow: "split" });
const prepared: PreparedBatch = { channel, messages, receipts };
await postBatch(prepared, botToken);
return prepared;
}Each message carries its own blocks and notification text;
degradations covers the whole conversion. When output actually splits, a
degradation records it (from: "single", to: "3 messages").
Post them into one thread, and stop at the first failure:
/** Keeps continuation messages in one thread; stops at the first failure. */
async function postBatch(prepared: PreparedBatch, botToken: string): Promise<void> {
let threadTs: string | undefined;
for (const [index, message] of prepared.messages.entries()) {
const ts = await postOne(prepared, index, message, threadTs, botToken);
threadTs ??= ts;
}
}Retry only the failed prepared payload. A timeout or a lost response is ambiguous: that
message may already be in the channel, so reconcile against your own correlation data
before retrying when duplicates are unacceptable. postOne in the full file carries that
distinction on a BatchPostError along with the index to resume from.
Posting several messages is not atomic. If message k fails, messages
1…k−1 are already visible in the channel. Stop on the first failure (as
above), keep the returned ts values and the index you reached, and retry only
the remaining messages into the same thread_ts. Re-running the whole loop
would duplicate the earlier chunks. For transient errors (ratelimited, HTTP
429), honor Slack's Retry-After header before resuming.
Without overflow: "split", convertToMessages() behaves like convert()
and returns a single degraded message. That is useful when you want the
multi-message result shape everywhere but splitting only where you opt in.
A single block that exceeds an entire empty message's budget is omitted with a degradation rather than emitted as an invalid message.
Notification text limits
The text fallback is independently protected: Slack truncates text around
40,000 characters, so slackmark clamps it there first and records a
degradation instead of letting Slack cut it arbitrarily.
Escape hatch: markdown-block strategy
strategy: "markdown-block" skips block mapping entirely and emits the raw
markdown into Slack markdown blocks (12,000-character chunks), letting
Slack's own markdown renderer do the work:
import { convert } from "slackmark";
const result = await convert(markdown, { strategy: "markdown-block" });This trades fidelity for simplicity: no native tables, charts, or degradation
detail. It is still a useful baseline when comparing outputs, or when a surface
renders markdown blocks well enough. It requires the markdownBlock
capability; without it the content degrades to a plain section.
The difference is easiest to see on one document. Default mapping turns this into a header, a native chart, and a styled warning quote:
## Weekly rollup ```mermaid pie "us-east-1" : 412 "eu-west-1" : 17 ``` > [!WARNING] > Retries still elevated.
Block Kit JSON
…
The same markdown with strategy: "markdown-block" becomes one block, the chart reduced to
its mermaid source:
## Weekly rollup ```mermaid pie "us-east-1" : 412 "eu-west-1" : 17 ``` > [!WARNING] > Retries still elevated.
Block Kit JSON
…