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 $$
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-nodeRequires 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.mjsQuickstart
Happy path only, with no retry or crash recovery:
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:
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.