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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- screenshot-automation Installs to ~/.agents/skills/screenshot-automation/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install screenshot-automation The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project screenshot-automation Installs to .agents/skills/screenshot-automation/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/screenshot-automation.zipunzip -o screenshot-automation.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) screenshot-automation PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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
- You want to fail CI when the UI changes visually: use visual-regression-testing
- The work is driving a multi-step web task rather than capturing: use browser-automation
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.
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
| Goal | Default |
|---|---|
| A few ad-hoc captures | npx playwright screenshot CLI |
| Many URLs, viewports or themes | Playwright script (Node or Python), one browser, many contexts |
| Social/OG images per page in a Next.js app | next/og ImageResponse (Satori) or opengraph-image.tsx |
| Social images from any site or static generator | HTML template rendered by Playwright at 1200x630 |
| Detect visual changes between builds | Hand off to $visual-regression-testing |
| Clicking through a flow to reach a state | Script 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. Avoidnetworkidleon sites with analytics beacons or websockets; it may never settle or settle too early. - Fonts:
document.fonts.readybefore 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 withpage.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: staticviaaddStyleTagfor full-page captures only. - Consent banners: set the site’s consent cookie or storage key before navigation
(
context.addCookiesoraddInitScript) 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; addstylemargins 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: 2doubles 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
clipif 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
curlthe 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.yaml
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.