Getting started
Your agent writes markdown. slackmark converts it into Slack Block Kit JSON
that renders natively: mentions, tables, code blocks, charts. Pass the result
to chat.postMessage. Anything that fell back or was truncated shows up in
degradations.
Install
pnpm add slackmark
# or: npm install slackmarkslackmark performs no I/O: it returns Block Kit JSON and your application posts it. The
examples here post with standard fetch, so no Slack SDK is required. If your application
prefers the official client, install @slack/web-api separately.
Runs on Cloudflare Workers, Bun, Deno, and Node (20.19+ within Node 20, or 22.12+).
Convert
import { convert } from "slackmark";
const { blocks, text, degradations, receipts } = await convert(
"# Deploy finished\n\nAll **32** checks passed.",
);Markdown never makes this call throw. Anything Slack cannot render falls back to the
closest supported form and is recorded in degradations. Bad options, a cancelled call,
or a timeout reject instead. The result has four parts:
| Field | Type | What it is |
|---|---|---|
blocks | Block[] | Block Kit JSON, ready for the blocks field of chat.postMessage |
text | string | Plain-text fallback for notifications and screen readers |
degradations | Degradation[] | Records of anything that fell back or was truncated |
receipts | UploadReceipt[] | Files uploaded to Slack; empty until you add image rendering |
# Deploy finished All **32** checks passed. Ship notes in `CHANGELOG.md`. ##### Rollback window
Block Kit JSON
…
Check degradations
Inspect degradations before posting: it tells you whether a fallback was recorded. The
example above has one, listed under the render. Slack headers stop at level 4, so the
##### line became bold rich text instead. Log the records at minimum;
see Validating output for the
record shape, offline validation, and known reporting gaps.
Post to Slack
You need a Slack app with a bot token (xoxb-…) that has the
chat:write scope, and
the bot must be a member of the target channel (/invite @YourApp). The
channel ID (C…) is in the channel's details pane. Keep the token in a secret,
never in code.
Save this as post.mjs and run it with SLACK_BOT_TOKEN=xoxb-… SLACK_CHANNEL_ID=C… node post.mjs:
import { convert } from "slackmark";
const botToken = process.env["SLACK_BOT_TOKEN"];
const channel = process.env["SLACK_CHANNEL_ID"];
if (!botToken || !channel) throw new Error("SLACK_BOT_TOKEN and SLACK_CHANNEL_ID required");
const { blocks, text } = await convert("# Deploy finished\n\nAll **32** checks passed.");
const response = await fetch("https://slack.com/api/chat.postMessage", {
method: "POST",
headers: {
authorization: `Bearer ${botToken}`,
"content-type": "application/json; charset=utf-8",
},
body: JSON.stringify({ channel, blocks, text }),
});
const body = await response.json();
if (!response.ok || body.ok !== true) throw new Error(`Slack rejected the post: ${body.error}`);That is the whole integration. Always send text alongside blocks; Slack uses it for push
notifications.
Check the response rather than the status code. Slack answers a rejected post with HTTP 200
and { "ok": false, "error": "not_in_channel" }, so a program that ignores the body exits
cleanly having posted nothing.
process.env is the Node and Bun spelling; on Workers the token arrives as an env binding
instead. Nothing else above is runtime-specific.
Handle a failed post
Slack can reject the post, and the network can lose it. Both cases are easier to recover from if you capture what you sent before the call, and keep it on the error:
export async function postMarkdown(
markdown: string,
botToken: string,
channel: string,
): Promise<PreparedPost> {
const result = await convert(markdown);
const prepared: PreparedPost = {
channel,
blocks: result.blocks,
text: result.text,
receipts: result.receipts,
};
await postPrepared(prepared, botToken);
return prepared;
}PreparedPost is a plain record of the four things the call needs. postPrepared wraps any
failure in a PostError that carries that record plus an ambiguous flag — false when
Slack definitely rejected the message, true when a timeout or a lost response means it may
have landed anyway. Both are in the full file.
If the post fails, hold on to the exact prepared envelope.
The posting flow itself is portable across Workers, Bun, Deno, and Node. How you inject the token is the only runtime-specific part.
Next
- Markdown for agents: what your LLM can emit and what each construct becomes.
- Mentions and Slack tokens: pinging users, channels, and groups from generated markdown, safely.
- Rasterize content: Mermaid, math, custom fenced languages, and Slack uploads.
- Converting many messages, or with non-default options? See
createConverter. - Try your own markdown in the playground.
- To preview Block Kit JSON in your own React UI, the separate react-blockkit package renders it. It is optional and never required for conversion.