ScreenshotNeo

BlogAI agents

How to capture a webpage screenshot with an AI agent and send it to Slack

Give an AI agent a browser runtime, capture a webpage with Playwright, and share the image using Slack’s current file upload API.

By the ScreenshotNeo team4 October 202612 min read

To capture a webpage screenshot with an AI agent and send it to Slack, give the agent an execution environment that can control a browser, use Playwright to capture the page, then upload the image through Slack’s external file upload flow. The agent does not capture a page by itself: your application or an agent-facing browser tool must execute the browser actions and return observations. Capture and delivery are separate steps, so you can choose the right screenshot scope and handle Slack permissions independently.

This guide uses Node.js, Playwright, and Slack’s current external upload API. Slack deprecated files.upload and says it was sunset on November 12, 2025; new integrations should use files.getUploadURLExternal followed by files.completeUploadExternal. Slack’s file guide describes the current sequence.

1. Give the agent a browser execution path

An AI model needs a tool or host application that can run browser operations. One practical setup is a Node.js service that receives an approved URL and capture options, executes Playwright, and returns a screenshot path or image bytes. The agent can decide which URL and capture mode to request; your runtime performs navigation and capture.

Playwright’s page.screenshot() saves an image from a browser page. Use a browser tool that exposes equivalent operations if your agent framework already provides one. For example, OpenAI’s computer-use tool is an exposed tool interface for computer interaction; it does not mean every model inherently has browser access. See the Playwright Page API and OpenAI computer-use guide.

Install the runtime

npm init -y
npm install playwright
npx playwright install chromium

Run this in an environment where Chromium can launch. In a container or server, ensure the runtime has the browser dependencies and enough memory for the pages you intend to capture.

2. Choose what the screenshot should show

Capture mode Use it when Playwright approach
Viewport You need the currently visible portion at a known window size. Set viewport dimensions and call page.screenshot().
Element You need a chart, card, form, or other specific region. Locate the element and call locator.screenshot().
Full page Content below the fold matters. Pass fullPage: true to page.screenshot().

Full-page capture can produce a tall, large image. Some sites load content only as you scroll, so a full-page screenshot may not include lazy-loaded material unless the page has first triggered that loading. For very long pages, consider capturing a specific element or multiple viewport sections instead.

PNG is lossless and useful for text or sharp UI; JPEG generally reduces size for photographic content; WebP can be a compact option where your downstream tools support it. Playwright screenshot options include a path, image type, quality for JPEG, full-page mode, and scale. The Playwright screenshot CLI documentation also describes output filenames, image type, and high-resolution/device-pixel capture options.

Device-pixel scale creates more pixels and can improve detail, but increases transfer and storage size. CSS-pixel scale is often adequate for review and keeps files smaller. Screenshots complement accessibility snapshots; use structured page information when the task is to understand text or page structure rather than inspect visual appearance.

3. Capture with Playwright in Node.js

This runnable script accepts a URL and output filename. It captures the viewport by default; pass --full for the full page. It uses a finite navigation timeout and closes the browser even if navigation or capture fails.

// capture.mjs
import { chromium } from 'playwright';
import { pathToFileURL } from 'node:url';

const target = process.argv[2];
const output = process.argv[3] ?? 'page.png';
const fullPage = process.argv.includes('--full');

if (!target) {
  console.error('Usage: node capture.mjs <https-url> [output.png] [--full]');
  process.exit(2);
}

const browser = await chromium.launch({ headless: true });
try {
  const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
  await page.goto(target, { waitUntil: 'domcontentloaded', timeout: 30_000 });
  await page.screenshot({ path: output, fullPage, type: 'png' });
  console.log(`Saved ${output}`);
} finally {
  await browser.close();
}
node capture.mjs https://example.com page.png
node capture.mjs https://example.com page-full.png --full

domcontentloaded waits for the document to be parsed, not for every image, font, or asynchronous widget. If the page needs more time, wait for a specific selector or a short delay based on the site’s behavior. Waiting for network idle can be unsuitable on pages with analytics, polling, or persistent connections. Prefer a meaningful page-ready condition over an arbitrary long wait.

Capture a specific element

const chart = page.locator('[data-testid="revenue-chart"]');
await chart.waitFor({ state: 'visible', timeout: 10_000 });
await chart.screenshot({ path: 'revenue-chart.png', type: 'png' });

Use a stable selector and wait for the element to be visible. If several elements match, make the locator specific. A missing or hidden target should be treated as a capture failure rather than silently uploading a blank image.

Set a viewport and device scale

const page = await browser.newPage({
  viewport: { width: 1280, height: 800 },
  deviceScaleFactor: 1
});
await page.goto(target, { waitUntil: 'domcontentloaded' });
await page.screenshot({ path: 'viewport.png', scale: 'css' });

Choose dimensions that match the question being answered. A mobile layout check needs a mobile-sized viewport; a desktop review needs a desktop-sized one. Increasing deviceScaleFactor or using device-pixel scale increases pixel count and file size.

4. Upload the screenshot to Slack

Slack’s external upload process has three actions: request an upload URL and file ID, transfer the raw bytes to that URL, then complete the upload and share it to a channel. Use a bot token authorized for the relevant Slack methods and destination. Set the channel ID explicitly; completing an upload without a channel leaves the file private rather than sharing it to a conversation.

Prepare the Slack app and token

Configure a Slack app with the file upload permissions required by the current API and grant it access to the destination conversation. Store the bot token as an environment secret such as SLACK_BOT_TOKEN; do not hard-code it or include it in agent-visible output. Use a channel ID rather than relying on a display name. Check Slack’s method documentation for current scope requirements and response fields: Working with files.

Runnable Node.js capture and upload

The example below captures a page, asks Slack for an upload URL using the file’s byte length, posts the bytes to the returned URL, and completes the upload into a channel with an optional comment.

// capture-and-send.mjs
import { chromium } from 'playwright';
import { readFile, stat } from 'node:fs/promises';

const target = process.env.TARGET_URL;
const channel = process.env.SLACK_CHANNEL_ID;
const token = process.env.SLACK_BOT_TOKEN;
const output = 'page.png';

if (!target || !channel || !token) {
  throw new Error('Set TARGET_URL, SLACK_CHANNEL_ID, and SLACK_BOT_TOKEN');
}

const browser = await chromium.launch({ headless: true });
try {
  const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
  await page.goto(target, { waitUntil: 'domcontentloaded', timeout: 30_000 });
  await page.screenshot({ path: output, type: 'png', fullPage: false });
} finally {
  await browser.close();
}

const bytes = await readFile(output);
const { size } = await stat(output);
const filename = output;
const slackApi = async (method, body) => {
  const response = await fetch(`https://slack.com/api/${method}`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${token}`,
      'Content-Type': 'application/json; charset=utf-8'
    },
    body: JSON.stringify(body)
  });
  if (!response.ok) throw new Error(`${method} HTTP ${response.status}`);
  const result = await response.json();
  if (!result.ok) throw new Error(`${method} failed: ${result.error}`);
  return result;
};

const upload = await slackApi('files.getUploadURLExternal', {
  filename,
  length: size
});

const transfer = await fetch(upload.upload_url, {
  method: 'POST',
  headers: { 'Content-Type': 'image/png' },
  body: bytes
});
if (!transfer.ok) throw new Error(`Image transfer failed: HTTP ${transfer.status}`);

const completed = await slackApi('files.completeUploadExternal', {
  files: [{ id: upload.file_id, title: filename }],
  channel_id: channel,
  initial_comment: `Screenshot of ${target}`
});
console.log(`Shared file ${completed.files?.[0]?.id ?? upload.file_id}`);

Run it with the environment variables set in your deployment secret manager:

TARGET_URL=https://example.com \
SLACK_CHANNEL_ID=C0123456789 \
SLACK_BOT_TOKEN=xoxb-your-token \
node capture-and-send.mjs

Use the upload URL exactly as returned and send the raw file bytes, not JSON or base64 text. Check the transfer HTTP status as well as Slack’s JSON ok field. The completion call can include initial_comment; use this when the image itself is the notification. If you need a distinct message after the upload, send one separately.

5. Add a separate Slack notification when needed

Use chat.postMessage when the upload’s initial comment is not sufficient—for example, when a workflow needs a follow-up status message or structured blocks. The method requires a channel and a token with chat:write. New apps do not automatically have permission to post in every public channel; Slack documents chat:write.public for broader public-channel access. Confirm that the app can access the selected channel.

const message = await slackApi('chat.postMessage', {
  channel,
  text: `Screenshot captured for ${target}`
});
console.log(message.ts);

When using blocks, provide top-level fallback text for notifications and assistive technology. Slack notes that screen readers default to the top-level text unless Slack can construct suitable text from supported blocks. See chat.postMessage.

6. Connect the workflow to an AI agent

Keep browser execution in a controlled host or tool integration. A useful tool contract accepts a validated URL, a capture mode, and optionally an element selector; it returns a file reference and capture status. A second tool or host step uploads the artifact to a configured Slack channel. This separation makes it easier to retry a failed upload without reopening the browser and to inspect capture failures without posting an empty file.

  1. Ask the agent for the target URL and the reason for the capture.
  2. Validate the URL and apply an allowlist or network policy appropriate to your environment.
  3. Have the agent select viewport, element, or full-page mode. Do not let a model invent a selector without handling a no-match result.
  4. Run browser navigation and capture with timeouts. Return a clear failure if navigation fails, a bot check appears, or the target element is absent.
  5. Upload only a successfully created image, then verify both transfer and completion responses.
  6. Return the Slack channel and file result to the agent without exposing credentials or sensitive page contents in logs.

Some pages require authentication, cookies, custom headers, or a particular user agent. Keep those values in the host runtime’s secret handling and supply only what the target is authorized to receive. Avoid exposing account data or customer information in channels that are broader than the page’s intended audience.

Or skip the browser setup

ScreenshotNeo is a website screenshot API and MCP server for developers. It can return a screenshot or PDF from one GET request, and its MCP server exposes screenshot tools to AI agents. Here is a direct API call; see the ScreenshotNeo API documentation for options and response details.

curl -G "https://api.screenshotneo.com/v1/shot" \
  -d access_key=YOUR_API_KEY \
  --data-urlencode url=https://stripe.com \
  -o shot.webp
import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`ScreenshotNeo request failed: ${res.status}`);
await Bun.write('shot.webp', res);

ScreenshotNeo removes cookie and consent banners, newsletter popups, and chat widgets before capture. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and responses identify the page verdict and billing status. Its MCP server provides take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 screenshots. You can then send the returned image through Slack’s upload flow described above.

Sign up for ScreenshotNeo’s free 1,000 screenshots per month, with no card required.

Troubleshooting

Symptom Likely cause Fix
Browser launch fails Chromium or its system dependencies are missing, or the runtime cannot launch a browser. Install Playwright’s browser with npx playwright install chromium and run in a compatible environment.
Navigation times out The page is slow, unreachable, or waits on resources that never finish. Check the URL and network access; use a suitable navigation condition and finite timeout. Wait for a specific ready selector rather than indefinitely waiting for network idle.
Screenshot is blank or incomplete Capture happened before rendering, lazy content was not triggered, or the site returned a bot check. Wait for a meaningful visible element; scroll or otherwise trigger lazy content when authorized; inspect the page before uploading and treat bot checks as capture failures.
Element selector times out The selector is wrong, matches nothing, or the target is hidden. Inspect the page structure, use a stable unique locator, and wait for visible state. Report no match rather than sending a substitute screenshot.
Slack says missing_scope or not_in_channel The token lacks the needed permission or the app has not joined/cannot access the destination. Grant the required file or chat scope, reinstall/reauthorize the app if needed, and ensure it has access to that channel.
Upload URL transfer fails The request sent the wrong body, content type, or file length, or the URL was not used as returned. Send raw bytes, provide the exact byte length in the URL request, use the returned upload URL, and inspect the HTTP status.
File uploaded but nobody sees it in the channel The completion call omitted the channel destination. Include the intended channel_id in files.completeUploadExternal and check its response.
Slack API returns ok: false Slack API errors can arrive in a successful HTTP response. Check the JSON ok property and log the error code safely; do not treat HTTP 200 alone as success.
Legacy upload tutorial fails It uses the deprecated files.upload method. Replace it with the external upload URL, raw byte transfer, and completion sequence.

Performance, reliability, and cost

Keep capture work bounded

Use explicit navigation and element timeouts, close browser contexts after use, and avoid launching a new browser for every tiny operation if your host can safely reuse a managed browser process. Bound concurrency according to available memory; many large full-page captures can consume substantial resources. This is an operational consideration rather than a published benchmark.

Make retries safe

Separate capture from delivery and preserve the completed image temporarily so an upload retry does not require repeating browser navigation. Retry transient network failures with a limit and backoff. Do not blindly retry a completion request after an ambiguous response without checking Slack state, since the first request may have succeeded. Record capture, transfer, and completion outcomes as separate stages.

Control file size and access

Choose viewport or element capture when a full-page image is unnecessary. Lower pixel scale or use an appropriate image format to reduce bytes. Treat screenshots as potentially sensitive artifacts: restrict channel membership and storage access, mask sensitive regions when appropriate, and keep image contents and credentials out of unauthorized logs.

Understand the cost model

Playwright’s direct route has no ScreenshotNeo API charge, but your deployment still bears browser runtime, compute, storage, and Slack app operational costs. The external Slack transfer requires sending the image bytes, so large screenshots use more bandwidth. If you use ScreenshotNeo instead, its listed plans are Free for 1,000 shots/month, Starter $5 for 3,000, Growth $15 for 15,000, Pro $39 for 60,000, Scale $99 for 250,000, and Business $249 for 1,000,000; yearly billing gives two months free, and every feature is on every plan. Only clean shots are billed under the stated product terms.

FAQ

Can an AI agent take a screenshot without a browser tool?

No. The agent needs a browser runtime or an exposed computer/browser tool that can execute the navigation and capture actions.

Should I attach the image or post a link?

Use the external upload flow to share the image in Slack. A link is suitable only if your workflow stores the image somewhere Slack recipients can access.

Can I send the screenshot and a message in one step?

Use initial_comment with the file completion call for a short accompanying note. Use chat.postMessage when you need a distinct notification.

Does a screenshot replace an accessibility snapshot?

No. A screenshot is for visual inspection; structured snapshots are generally more useful for reading page structure and text.