Screenshot Automation skill for Codex

Same page, same pixels, every run. Codex captures pages with Playwright the deterministic way: fonts awaited, lazy images scrolled into view, animations disabled, clocks frozen and volatile regions masked, across desktop and mobile viewports in one browser. It also renders per-post Open Graph images at 1200x630 from an HTML template or next/og, with escaped titles and embedded fonts.

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- screenshot-automation

Installs to ~/.agents/skills/screenshot-automation/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $screenshot-automation, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Captures deterministic web screenshots with Playwright (full page, element, multi-viewport, dark mode, retina) and generates Open Graph and social share images from HTML templates or Satori/next/og, in batches from a URL list or sitemap. Use when the user wants screenshots of many pages, app store or docs screenshots refreshed automatically, OG images per blog post, a cookie banner or animation ruining captures, or blank and half-loaded screenshots.

Use it when

  • Many pages need screenshots at several viewports or themes
  • Docs, marketing or store screenshots should refresh automatically
  • Each page needs its own social share image
  • Captures come out blank, half-loaded or full of banners

Use something else when

How to check the result

Open a sample of outputs including the longest page, check pixel dimensions, and run the batch twice on a static page to confirm identical hashes.

The mistake it prevents

Using fixed sleeps or networkidle instead of waiting for fonts and content, which gives fallback fonts and half-loaded images on slow runs.

~/.agents/skills/screenshot-automation/SKILL.md

--- name: screenshot-automation description: Captures deterministic web screenshots with Playwright (full page, element, multi-viewport, dark mode, retina) and generates Open Graph and social… license: MIT ---

Done well means: the same page produces the same pixels on every run, every capture is fully loaded (fonts, lazy images, data), noise such as cookie banners and animations is removed on purpose, and output files are named and sized for where they will be used.

Choose the approach

GoalDefault
A few ad-hoc capturesnpx playwright screenshot CLI
Many URLs, viewports or themesPlaywright script (Node or Python), one browser, many contexts
Social/OG images per page in a Next.js appnext/og ImageResponse (Satori) or opengraph-image.tsx
Social images from any site or static generatorHTML template rendered by Playwright at 1200x630
Detect visual changes between buildsHand off to $visual-regression-testing
Clicking through a flow to reach a stateScript the steps with $browser-automation, then capture here

Playwright browsers download on first use (npx playwright install chromium, or python -m playwright install chromium). That needs network; on Linux, --with-deps also needs root. In a sandboxed Codex session, ask for approval or check whether the browsers are already cached (~/.cache/ms-playwright, ~/Library/Caches/ms-playwright).

Quick CLI capture

npx playwright screenshot --viewport-size=1440,900 --full-page \
  --wait-for-timeout=1000 https://example.com home-1440.png
npx playwright screenshot --device="iPhone 15" --color-scheme=dark \
  https://example.com home-iphone-dark.png

Batch script (Node)

// capture.mjs  -  node capture.mjs urls.txt out/
import { chromium } from 'playwright';
import { readFile, mkdir } from 'node:fs/promises';

const [listFile, outDir = 'out'] = process.argv.slice(2);
const urls = (await readFile(listFile, 'utf8')).split('\n').map(s => s.trim()).filter(Boolean);
const viewports = [{ name: 'desktop', width: 1440, height: 900 }, { name: 'mobile', width: 390, height: 844 }];
await mkdir(outDir, { recursive: true });

const browser = await chromium.launch();
const failures = [];
for (const vp of viewports) {
  const context = await browser.newContext({
    viewport: { width: vp.width, height: vp.height },
    deviceScaleFactor: 2,                 // retina-sharp output
    colorScheme: 'light',
    reducedMotion: 'reduce',
    locale: 'en-US',
    timezoneId: 'UTC',                    // stable dates in the page
  });
  await context.addInitScript(() => localStorage.setItem('cookie-consent', 'dismissed'));
  for (const url of urls) {
    const page = await context.newPage();
    try {
      await page.goto(url, { waitUntil: 'load', timeout: 45_000 });
      await settle(page);
      const slug = new URL(url).pathname.replace(/\W+/g, '-').replace(/^-|-$/g, '') || 'home';
      await page.screenshot({
        path: `${outDir}/${slug}-${vp.name}.png`,
        fullPage: true,
        animations: 'disabled',
        caret: 'hide',
        mask: [page.locator('[data-testid="live-clock"]')],   // volatile regions
      });
    } catch (err) {
      failures.push(`${vp.name} ${url}: ${err.message.split('\n')[0]}`);
    } finally {
      await page.close();
    }
  }
  await context.close();
}
await browser.close();
if (failures.length) { console.error(failures.join('\n')); process.exit(1); }

async function settle(page) {
  await page.addStyleTag({ content: '#cookie-banner, .intercom-launcher { display: none !important; }' });
  await page.evaluate(async () => {
    await document.fonts.ready;
    for (let y = 0; y < document.body.scrollHeight; y += window.innerHeight) {
      window.scrollTo(0, y);
      await new Promise(r => setTimeout(r, 150));          // trigger lazy images
    }
    window.scrollTo(0, 0);
    await Promise.all([...document.images].filter(i => !i.complete)
      .map(i => new Promise(r => { i.onload = i.onerror = r; })));
  });
}

Python equivalent uses the same options in snake_case (full_page=True, device_scale_factor=2, reduced_motion="reduce").

Making captures deterministic

  • Wait for content, not time. Prefer await page.locator('main h1').waitFor() or a page-specific ready signal over fixed sleeps. Avoid networkidle on sites with analytics beacons or websockets; it may never settle or settle too early.
  • Fonts: document.fonts.ready before capture, or text renders in the fallback font.
  • Animations: animations: 'disabled' finishes CSS animations and transitions; reducedMotion: 'reduce' stops well-behaved JS animations. Carousels and video need CSS overrides or masking.
  • Volatile data: mask clocks, avatars, ads and counters with mask, or freeze time with page.clock.setFixedTime(new Date('2026-01-15T10:00:00Z')) before navigation.
  • Sticky headers in full-page shots repeat or float mid-page; set them to position: static via addStyleTag for full-page captures only.
  • Consent banners: set the site’s consent cookie or storage key before navigation (context.addCookies or addInitScript) rather than clicking “accept” on the user’s behalf for third-party sites.
  • Element shots: await page.locator('#pricing').screenshot({ path }) crops to the element, including its padding; add style margins if the design needs breathing room.

Social and Open Graph images

Target 1200x630 PNG (or JPEG under 1 MB for photo-heavy designs), with key text inside the central safe area because some platforms crop to square.

HTML template route (works with any stack):

const page = await browser.newPage({ viewport: { width: 1200, height: 630 }, deviceScaleFactor: 1 });
for (const post of posts) {
  await page.setContent(renderTemplate(post), { waitUntil: 'load' });   // escape post fields
  await page.evaluate(() => document.fonts.ready);
  await page.screenshot({ path: `public/og/${post.slug}.png` });
}
  • Embed fonts as local files or data URLs in the template; remote font fetches fail offline and slow every capture.
  • Escape titles before inserting into HTML; a < in a post title breaks the layout.
  • Long titles: clamp with CSS (-webkit-line-clamp: 3) and test the longest real title.

Next.js route (Satori): ImageResponse from next/og renders a JSX subset. Only flexbox layout (no grid), a limited set of CSS properties, and fonts passed as TTF/OTF/WOFF ArrayBuffers (not WOFF2). Every div with more than one child needs display: 'flex'.

Wire the result with <meta property="og:image" content="https://.../og/slug.png">, og:image:width/height, and twitter:card = summary_large_image. Absolute URLs only.

Gotchas

  • deviceScaleFactor: 2 doubles pixel dimensions; an OG image must be generated at scale 1 or resized, or it ships at 2400x1260.
  • Full-page screenshots of very tall pages (over roughly 16,000 px) can be clipped by the browser’s texture limits; capture in sections with clip if needed.
  • Pages behind login: create a storage state once (context.storageState({ path })) and reuse it; never put real passwords in the capture script.
  • Headless and headed Chromium render fonts slightly differently, and Linux differs from macOS. Generate committed images on one platform (often CI) consistently.
  • Respect robots and rate limits when capturing third-party sites; add a delay between pages.

Verification

  • Open three random outputs and the longest page; check fonts, images and no banners.
  • Check dimensions: python3 -c "import struct,sys;d=open(sys.argv[1],'rb').read(24);print(struct.unpack('>II',d[16:24]))" out/home-desktop.png.
  • Run the batch twice and compare file hashes for a static page; differences mean something is still volatile.
  • For OG images, test the live URL in a card validator or curl the meta tags.

Report

**Captured:** 48 URLs x 2 viewports (1440x900, 390x844) at 2x, light theme -> out/ (96 PNG)
**Determinism:** fonts awaited, animations disabled, clock fixed, 2 regions masked
**Failures:** 1 (/admin returns 401, skipped)
**OG images:** 31 posts -> public/og/*.png, 1200x630, longest title wraps to 3 lines

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Screenshot Automation"
  short_description: "Same page, same pixels, every run"
  default_prompt: "$screenshot-automation Screenshot every URL in sitemap.xml at desktop and mobile sizes"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc