Guide for developers
Charts in Slack from n8n and AI agents
An image block takes the URL of a publicly hosted image S1. A ChartPNG/chart URL is that kind of URL, so a workflow or an agent can put a chart in a message without a Slack app that uploads files. This guide has the message to copy, the limits that matter, and the two ways to send it.
The short version
- Put the chart URL in
image_urlof animageblock, and always writealt_textS1. image_urlholds up to 3000 characters andalt_textup to 2000 S1.- When the URL does not fit, shorten the config, or POST it and upload the PNG with Slack's files API S3.
- In n8n, start from a template. An agent can use
@chartpng/mcpor one HTTP call.
The Block Kit message
This is a chart drawn by ChartPNG with the kpi preset. The image block has the chart URL and an alt text that says the finding. An image block needs type and alt_text, and one of image_url or slack_file S1. Slack documents that blocks can be sent with incoming webhooks S2, so you can paste this JSON as the body of a webhook call.
{
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Weekly report*"
}
},
{
"type": "image",
"image_url": "https://chartpng.dev/chart?c=%7B%22type%22%3A%22line%22%2C%22data%22%3A%7B%22labels%22%3A%5B%22W1%22%2C%22W2%22%2C%22W3%22%2C%22W4%22%2C%22W5%22%2C%22W6%22%2C%22W7%22%2C%22W8%22%5D%2C%22datasets%22%3A%5B%7B%22label%22%3A%22MRR%22%2C%22data%22%3A%5B8200%2C8650%2C8400%2C8900%2C9100%2C9350%2C9100%2C9840%5D%7D%5D%7D%2C%22options%22%3A%7B%22plugins%22%3A%7B%22title%22%3A%7B%22display%22%3Atrue%2C%22text%22%3A%22MRR%22%7D%2C%22subtitle%22%3A%7B%22display%22%3Atrue%2C%22text%22%3A%22Last%208%20weeks%22%7D%2C%22chartpng%22%3A%7B%22preset%22%3A%22kpi%22%2C%22previous%22%3A9100%2C%22prefix%22%3A%22%24%22%7D%7D%7D%7D&w=800&h=400&dpr=2",
"alt_text": "MRR is $9,840, up 8.1% on last week, its highest in eight weeks, after dips in weeks 3 and 7."
}
]
}The URL is 631 characters long. The config behind it, which is the part you change:
{
"type": "line",
"data": {
"labels": [
"W1",
"W2",
"W3",
"W4",
"W5",
"W6",
"W7",
"W8"
],
"datasets": [
{
"label": "MRR",
"data": [
8200,
8650,
8400,
8900,
9100,
9350,
9100,
9840
]
}
]
},
"options": {
"plugins": {
"title": {
"display": true,
"text": "MRR"
},
"subtitle": {
"display": true,
"text": "Last 8 weeks"
},
"chartpng": {
"preset": "kpi",
"previous": 9100,
"prefix": "$"
}
}
}
}The kpi preset draws a big number, the change against previous and a trend line. See KPI with a sparkline, andcomparison with the previous period for a second chart in the same message. The free plan adds a small watermark.
Limits that matter
image_url: 3000 characters. Slack's page says "Maximum length for this field is 3000 characters." S1 A GET URL carries the whole config percent-encoded, so it is about 1.5 times the size of the JSON.alt_text: 2000 characters. Same page S1. Write the answer, not a label: "MRR is $9,840, up 8.1% on last week", not "MRR chart".- Image source. Provide either
image_urlorslack_fileS1.
When the URL does not fit
- Shorten the config. Fewer points, shorter labels, no options you do not need. The n8n KPI template stops with an error when a URL goes over 3000 characters instead of posting a broken message.
- POST the config and upload the PNG.
POST /charttakes up to 16 KB of config and returns the PNG, so the config no longer has to fit in a URL.
curl -X POST https://chartpng.dev/chart \
-H 'Content-Type: application/json' \
-d '{"c":{"type":"bar","data":{"labels":["Mon","Tue","Wed"],"datasets":[{"data":[12,19,7]}]}},"w":800,"h":450,"dpr":2}' \
-o chart.pngThen hand the PNG to Slack's files API. This is explained here, not built into ChartPNG: you need a Slack app and a token with the files:write scope S3. As Slack describes it, files.getUploadURLExternal returns a URL for the fileS3; you send the PNG bytes to that URL, and files.completeUploadExternalfinishes the upload. Without a channel the file is only hosted in Slack, so name the channel to share it thereS3. Slack scans uploaded files for malware before making them available S3.
n8n
Two importable workflows post to Slack. The n8n guide explains how to import and set them up.
- weekly-kpi-report-to-slack.json: builds two chart URLs (a KPI and a comparison) and posts them as two image blocks with alt text in one message. It uses the image block of this guide and checks the 3000 character limit.
- weekly-chart-to-slack.json: renders a bar chart with a POST to ChartPNG and uploads the PNG with the Slack node (Slack's files API, S3). It does not depend on the URL length.
AI agents
An agent has two ways to get a chart into the message above.
@chartpng/mcp. Itschart_urltool returns a PNG URL the agent can put inimage_url. It refuses URLs over 8,000 characters, which is far above Slack's 3000 S1, so the agent must checkurl.length <= 3000itself; when it is over, userender_chartand Slack's files API. Itsrender_charttool returns the PNG itself, with configs up to 16 KB. Run it withnpx -y @chartpng/mcp; the key is optional.- An HTTP call. Build the
/chart?c=...URL as in the example, orPOST /chartand keep the PNG.
Language models often write a config that is almost JSON. ChartPNG repairs the usual slips (code fences, single quotes, unquoted keys, trailing commas, a config sent as an escaped string, a missing chart type) and removes functions written as text without running them. This always applies; there is no switch. When a repair was needed, the response carries the X-ChartPNG-Fixes header with the codes, separated by commas. When nothing was repaired the header is absent. An agent that calls the API can read it, tell the user, and fix its config. The full list of codes is in the API reference.
curl -si -X POST https://chartpng.dev/chart \
-H 'Content-Type: application/json' \
--data-binary @- -o /dev/null -D - <<'EOF'
{"c":"{type:'bar',data:{labels:['Mon','Tue'],datasets:[{data:[12,19,],},],},}"}
EOF
# HTTP/2 200
# content-type: image/png
# x-chartpng-fixes: trailing_comma,single_quotes,unquoted_keysSlack's limit is on the URL you send S1, so an agent that builds URLs should check the length against 3000 before it posts, and switch to POST when it is over.
Checklist before you post
image_urlis a publicly hosted image URL S1, 3000 characters or fewer.alt_textsays the finding, 2000 characters or fewer.- The URL opens in a browser without a key in it.
- One image block for each chart. Long config: POST and upload.
What we could not confirm
- Whether an incoming webhook renders an image block in practice. S2 says blocks work with incoming webhooks and does not name the image block. We did not post to a workspace.
- Whether Slack fetches a ChartPNG URL of up to 3000 characters without trouble. S1 gives the length limit, nothing about how Slack fetches the image or what it shows when the fetch fails.
- The method reference pages for files.getUploadURLExternal and files.completeUploadExternal. We read only the overview in S3, so the upload steps below are explained and not shown as code.
- That Slack loads the image from its URL and shows it in the message: we infer it from the image_url field and from the n8n template, S1 does not say how the image is fetched.
- Whether @chartpng/mcp shows an agent the repairs made to its config. Its README does not mention them. Over HTTP, the X-ChartPNG-Fixes response header does.
Sources
All pages read on 3 Nov 2026. Quotes are exact and short. Web pages change, so check the date.
- S1Slack: Image blockhttps://docs.slack.dev/reference/block-kit/blocks/image-block
Quote: "Maximum length for this field is 3000 characters."
Read 3 Nov 2026. The same page gives 2000 characters for alt_text, marks type and alt_text as required, and says: "You must provide either an image_url or slack_file."
- S2Slack: Sending messages using incoming webhookshttps://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks
Quote: "use all the usual formatting and layout blocks with incoming webhooks"
Read 3 Nov 2026. The page does not name the image block.
- S3Slack: Working with fileshttps://docs.slack.dev/messaging/working-with-files
Quote: "The response will contain a URL."
Read 3 Nov 2026. About files.getUploadURLExternal. It also says: "If not specified, the file will only be uploaded, i.e., hosted in Slack." (files.completeUploadExternal without a channel) and "Slack will scan the file for malware before making it available." On scopes: "The files:write scope allows your app to upload files."