Rasterize content

Slack has no Mermaid and no LaTeX. slackmark-node runs both through a local Chromium and posts the result as an uploaded image. This is the message the quickstart below produces:

```mermaid
flowchart LR
  Agent --> Slack
```

$$
x^2 + y^2 = z^2
$$
The quickstart program's markdown, converted. Recorded from a real slackmark-node run rather than produced live, so the pictures are the renderer's own PNGs.
Block Kit JSON

Chromium cannot run in a browser, so this figure replays a committed recording of a real createNodeConverter() run (slackmark-node with mermaid 11.16.0, KaTeX 0.18.1, Chromium via puppeteer 25.3.0). Regenerate it with pnpm docs:raster-examples. The pictures are painted from PNG bytes committed to this repo (528×140, 5 kB, renderer bytes; 324×126, 4 kB, renderer bytes), substituted for the payload's image reference because a browser cannot fetch it. A capturing stub stood in for the Slack uploader, so the slack_file IDs below are synthetic; a real upload returns workspace file IDs. Nothing else was touched: the JSON below is what convert() returned.

[
  {
    "type": "image",
    "alt_text": "mermaid diagram",
    "slack_file": {
      "id": "F09DOCS0001"
    }
  },
  {
    "type": "image",
    "alt_text": "display math",
    "slack_file": {
      "id": "F09DOCS0002"
    }
  }
]

Install

pnpm add slackmark slackmark-node

Requires Node.js 22.12+. This complete flow uses standard fetch; install a Slack SDK only if your app otherwise needs it.

Create a bot with chat:write and files:write, invite it to the channel, save the first program below as quickstart.mjs, then run:

SLACK_BOT_TOKEN=xoxb-… SLACK_CHANNEL_ID=C… \
  node quickstart.mjs

Quickstart

Happy path only, with no retry or crash recovery:

quickstart.mjs
import { FetchSlackUploader } from "slackmark/slack";
import { createNodeConverter } from "slackmark-node";

const token = process.env.SLACK_BOT_TOKEN;
const channel = process.env.SLACK_CHANNEL_ID;
if (!token || !channel) throw new Error("SLACK_BOT_TOKEN and SLACK_CHANNEL_ID required");

const markdown = `\`\`\`mermaid
flowchart LR
  Agent --> Slack
\`\`\`

$$
x^2 + y^2 = z^2
$$`;

const converter = createNodeConverter({ uploader: new FetchSlackUploader({ token }) });
try {
  const { blocks, text } = await converter.convert(markdown, { timeoutMs: 30_000 });
  const response = await fetch("https://slack.com/api/chat.postMessage", {
    method: "POST",
    headers: {
      authorization: `Bearer ${token}`,
      "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");
} finally {
  await converter.close();
}

Shut down cleanly

For production, the complete program below separates a definite rejection from an ambiguous timeout or lost response, and preserves a conversion error and a close error together. That last part is the shape worth copying — converter is the one built above, and postPrepared is the prepared-envelope call the full file carries:

post-rendered-markdown.ts · excerptView on GitHub
let primaryError: unknown;
try {
  const result = await converter.convert(markdown, { timeoutMs: CONVERT_TIMEOUT_MS });
  await postPrepared(token, {
    channel,
    blocks: result.blocks,
    text: result.text,
    receipts: result.receipts,
  });
} catch (error) {
  primaryError = error;
  throw error;
} finally {
  try {
    await converter.close({ behavior: "drain", timeoutMs: CLOSE_TIMEOUT_MS });
  } catch (closeError) {
    if (primaryError !== undefined) {
      throw new AggregateError([primaryError, closeError], "Operation and close both failed");
    }
    throw closeError;
  }
}

That is the same prepared-envelope pattern as Getting started, and it is why a close failure can never quietly replace the error that actually broke the run.

On post failure, the thrown error retains the exact prepared envelope.

Persist prepared -> posted only when your app needs crash recovery. That persistence still does not make Slack posting idempotent. slackmark has no outbox and no posting workflow.

Keep receipts and source-bearing image URLs out of broad logging. Receipts may contain private file IDs.

Next: Custom renderers, Upload to Slack, and Production.