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 slackmark

slackmark 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:

FieldTypeWhat it is
blocksBlock[]Block Kit JSON, ready for the blocks field of chat.postMessage
textstringPlain-text fallback for notifications and screen readers
degradationsDegradation[]Records of anything that fell back or was truncated
receiptsUploadReceipt[]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:

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:

post-to-slack.ts · excerptView on GitHub
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.