snappy-browse skill
clear-requestswrite-reversibleclick refwrite-reversibleclosewrite-reversibleextract js-expressionreadmetrics metric-namereadnavigate urlwrite-reversiblerequests filter?readsnapshotread$ npx snappy-skills install snappy-browse
You are operating the browser automation primitive for the Snappy stack. Every web interaction (Skool, LinkedIn, Canva, YouTube Studio, Instagram, Luma, GitHub web UI) routes through this skill. ActiveCampaign is NOT in use. The full SKILL.md has platform recipes and troubleshooting; the rules below are load-bearing.
typescriptimport { runBrowser, navigate, extract, snapshot, click, fill, getUrl, screenshotPage, close } from "../snappy-browse/session.ts";
session.ts is the browser road and it reads no credential; api.ts re-exports every symbol above and adds the Canva Connect client, so importing the session functions from api.ts makes your hand declare CANVA_CLIENT_ID and CANVA_CLIENT_SECRET it will never spend (a requirement is read one hop through an import). Import session.ts unless you want Canva.
CLI:
bashnpx tsx ~/.claude/skills/snappy-browse/api.ts navigate "https://example.com"
npx tsx ~/.claude/skills/snappy-browse/api.ts extract "document.title"
npx tsx ~/.claude/skills/snappy-browse/api.ts snapshot
npx tsx ~/.claude/skills/snappy-browse/api.ts click @e3
npx tsx ~/.claude/skills/snappy-browse/api.ts close
| Function | Purpose |
|---|---|
runBrowser(command, args?) |
Run arbitrary agent-browser command with session isolation |
navigate(url, statePath?) |
Navigate to URL, optionally loading auth state |
extract(expression) |
Extract data via JavaScript eval in browser |
snapshot() |
Take interactive snapshot, return element refs |
click(ref) |
Click element by ref (e.g. @e3) |
fill(ref, value) |
Fill input field by ref |
getUrl() |
Get current page URL |
screenshotPage(path?, full?) |
Take screenshot |
close() |
Close the current session |
AGENT_BROWSER_SESSION to a unique value before any command:bash export AGENT_BROWSER_SESSION="${AGENT_BROWSER_SESSION:-task-$-$(date +%s)}"
open must include --state ~/.openclaw/workspace/<platform>-auth.json. Subsequent commands inherit.3b. ALWAYS save state back when done. Before closing the session, run agent-browser state save ~/.openclaw/workspace/<platform>-auth.json. This refreshes the cookies so the next agent doesn't hit expired auth. This is mandatory, not optional.
3c. Verify URL before saving state. Before state save, always get url and confirm you're on the correct platform. Never save state if the browser navigated away from your target platform.
agent-browser snapshot -i to see the page before clicking blind.? or &.eval. agent-browser eval "document.querySelectorAll(...).map(...)" beats clicking through pages one by one.@ref from snapshot is the default, not semantic text. agent-browser snapshot -i → click @e3 is the first choice for anything clickable. Semantic text locators (find text "Submit" click) are a fallback, not the default — they throw Playwright strict-mode violations when the label appears more than once on the page and the click silently does nothing. See "Critical: strict-mode violations" below.agent-browser --session "$AGENT_BROWSER_SESSION" close. Never pkill the global daemon when concurrent agents run.snappy-settings/.env.cache. Never hardcode passwords.Located at ~/.openclaw/workspace/<platform>-auth.json. Ready: skool, linkedin, instagram, luma, canva, github. On-demand: activecampaign, youtube.
When <platform>-auth.json is empty or stale and there's no programmatic login, piggyback on a Chrome window that's already logged in via the Chrome DevTools Protocol debug port:
bash# 1. Confirm Chrome is listening on CDP (usually 9222)
lsof -i :9222 | head
# 2. Attach — do NOT pass --state, you want the running profile's real session
agent-browser --session "recover-$" connect 9222
# 3. Verify you landed on the logged-in page (e.g. linkedin.com/feed/)
agent-browser --session "recover-$" get url
# 4. Save the live session's cookies into the auth file
agent-browser --session "recover-$" state save ~/.openclaw/workspace/<platform>-auth.json
When to use: the auth file is 36 bytes ({"cookies":[],"origins":[]}), a prior run wiped it, credentials aren't in .env.cache, or the platform uses OAuth/2FA that blocks headless login. If Chrome isn't already listening on 9222, start it with --remote-debugging-port=9222.
Critical: connect attaches to the running profile. Do not combine with --state — that layers a stored context over a live browser and produces empty saves. This is how we recovered LinkedIn auth 2026-04-14 after the fetcher's state-save bug wiped linkedin-auth.json.
Canva requires --headed mode. Headless hits Cloudflare Turnstile with no programmatic bypass. Canva also uses Google OAuth (no email/password flow) so re-auth requires manual headed login: agent-browser --headed --state ~/.openclaw/workspace/canva-auth.json open "https://www.canva.com/", log in via Google popup, then agent-browser state save ~/.openclaw/workspace/canva-auth.json.
Canva auth-liveness check (important). Canva's logged-out homepage keeps the friendly title "Canva: Visual Suite for Everyone" — document.title is not a valid auth signal and will fool you. Use canvaAuthLive() from api.ts which checks for a Log in/Sign up link in the top nav: present = logged out, absent = logged in. Also call canvaDismissCookieBanner() after open — the "But first, cookies 🍪" banner covers the viewport and blocks clicks. OAuth popup requires a physical GUI session, so if canvaAuthLive() returns {live:false}, stop and ask Robert to re-auth on the Mac Mini — Playwright from SSH cannot drive the Google popup.
bashagent-browser --state <auth.json> open "<url>" # Launch with cookies
agent-browser snapshot -i # Interactive elements
agent-browser click @e3 # By ref
agent-browser fill @e5 "value" # Clear + fill
agent-browser find text "X" click # Semantic
agent-browser eval "JS_EXPRESSION" # Extract data
agent-browser get url # Verify navigation
agent-browser wait 2000 # Wait ms
agent-browser screenshot [path] # Capture
Shell env resets between Bash calls. AGENT_BROWSER_SESSION exports are lost. Chain all commands in a single Bash call: agent-browser --state ... open URL && sleep 3 && agent-browser eval "...". The daemon persists but your env var does not.
Playwright runs in strict mode. A locator that matches more than one element throws strict mode violation: getByText('X') resolved to N elements and the action silently fails. Seen 58× in recent logs on snappy.ai pages where headings AND body copy share the same word ("Build and control..." + "Control: keep agents..." both match getByText("Control")).
Selector priority — use the first one that works:
@ref from snapshot -i — always unique, always preferred. Re-snapshot if stale.agent-browser find role button name "Sign In" click. Role scopes the match to actual buttons, not surrounding prose.--exact — find text "Sign In" --exact click. Only when the label is guaranteed unique on the page.snapshot -i -s "#sidebar" or -s "[class*='Nav']", then click the ref from the scoped result.click "[class*=\"PrimaryButton\"]". Class hashes rotate but prefixes don't.:nth-match / index — find text "Control" --nth 0 click. Last resort; order changes break you.Rules:
click or find text. If the label could appear in headings, nav, and body copy, use snapshot -i + @ref.snapshot -i -C and grep the output for the label. Count > 1 means disambiguate.snapshot -i → click @eN for anything past a dead-simple landing page. Semantic locators are a fallback, not the default (this overrides older guidance in the platforms and troubleshooting docs).data-testid to the clickable element. Until then, playbook above holds.Attaching a custom thumbnail to a Featured section item (profile → Featured → edit pencil) uses a nested two-pencil pattern with a hidden file input. Rediscovered the hard way (2026-04-11) — not obvious from a snapshot.
Edit is an <a>, not a <button>. querySelectorAll('button') misses it. Use snapshot -i + @ref, or role-scope: find role link name "Edit" click.aria-label="Edit media" — same label as the modal header. Scope to button-only: find role button name "Edit media" click, not semantic text.input[type=file] is hidden and only populates after clicking the pencil. A stale querySelectorAll('input[type=file]') before the click returns empty. Click the pencil first, then query the input.upload and preview swap in the modal. Screenshot before and after to confirm.<img> src on the Featured card. Compare the media key (D4E2DAQH...) to the pre-edit value. Same-session toasts are not evidence — see the verification-certificate rule in skill-spec.md.Full recipe (selectors, timings, screenshot checklist) lives in platforms.md under the LinkedIn section. This loader entry exists so the next agent doesn't burn an hour rediscovering the nested-pencil pattern.
get text body on heavy pages#Pages like Skool feeds return 270KB+ from get text body. Always use agent-browser eval with targeted DOM selectors instead. For Skool community feeds, the working selector chain is:
bashagent-browser eval "JSON.stringify(Array.from(document.querySelectorAll('[class*=\"PostItemContentWrapper\"]')).map(el => {const lines = el.innerText.split('\n').filter(l => l.trim()); return {likes: lines[0], author: lines[1], date: lines[2], category: lines[3], title: lines[4], preview: lines.slice(5).join(' ').substring(0,120), comments: el.closest('[class*=\"PostItemWrapper\"]')?.querySelector('[class*=\"CommentsCount\"]')?.textContent?.trim() || '0'}}))"
Browser automation of Canva hits Cloudflare Turnstile and requires --headed mode. For programmatic Canva work (autofill, export, asset management), use the Canva Connect API via canva-api.ts instead.
typescriptimport { canva } from "../snappy-browse/canva-api.ts";
CLI:
bashnpx tsx ~/.claude/skills/snappy-browse/canva-api.ts templates # list brand templates
npx tsx ~/.claude/skills/snappy-browse/canva-api.ts dataset <id> # show autofill fields
npx tsx ~/.claude/skills/snappy-browse/canva-api.ts upload <file> # upload image asset
npx tsx ~/.claude/skills/snappy-browse/canva-api.ts autofill <id> '<json>' [title]
npx tsx ~/.claude/skills/snappy-browse/canva-api.ts export <designId> [png|pdf|jpg]
npx tsx ~/.claude/skills/snappy-browse/canva-api.ts designs # list recent designs
npx tsx ~/.claude/skills/snappy-browse/canva-api.ts folders [parentId] # list folders (default: root)
npx tsx ~/.claude/skills/snappy-browse/canva-api.ts folder-items <id> # list items in folder
npx tsx ~/.claude/skills/snappy-browse/canva-api.ts create-folder <name> [parentId]
npx tsx ~/.claude/skills/snappy-browse/canva-api.ts move <itemId> <toFolderId>
npx tsx ~/.claude/skills/snappy-browse/canva-api.ts scan # overview of root items
npx tsx ~/.claude/skills/snappy-browse/canva-api.ts import <file> [title] # import image as editable design (PNG→PDF→design)
npx tsx ~/.claude/skills/snappy-browse/canva-api.ts import-url <url> [title] # import design from URL
Key API functions: canva.listTemplates(), canva.getDataset(id), canva.uploadAsset(path), canva.autofill(templateId, data, title), canva.exportDesign(designId, format), canva.listDesigns(), canva.createDesign(), canva.getDesign(id), canva.createFolder(name, parentId), canva.listAllFolderItems(folderId), canva.moveItem(itemId, toFolderId), canva.deleteFolder(id), canva.importDesign(filePath, title), canva.importDesignFromUrl(url, title), canva.resizeDesign(designId, w, h, title), canva.createComment(designId, message), canva.replyToComment(designId, threadId, message).
| Folder | ID | Purpose |
|---|---|---|
| Snappy | FAHGr-3HtNU |
Current brand/business work |
| Clients | FAHGrwJVH4w |
Client delivery designs |
| Amazon Era | FAHGrxHZD-4 |
Legacy Amazon/Amzsite designs |
| The Orange Brand | FAHGr9xoF2g |
TOB/HealthySeller brand |
| Personal | FAHGr3Vstes |
Resumes, personal docs |
| Archive | FAHGr40cMVQ |
Untitled/old/junk designs |
| Base Ref Templates | FAHGr065Z-E |
PIL-generated grounding refs for image system |
| Generated Images | FAHGryjpN3c |
Auto-uploaded outputs from generate.sh --canva |
| Course Images | FAHGsRxfKoU |
Lesson/course imagery (under Snappy) |
| YouTube Thumbnails | FAHGsUIkggM |
A/B thumbnail variants (under Snappy) |
| Blog Heroes | FAHGsVN6qK4 |
Blog post hero images (under Snappy) |
| LinkedIn Featured | FAHGsTUK1S0 |
Featured section thumbnails (under Snappy) |
| Logos Flat | FAHGszPSUR4 |
Black-on-transparent flat logos (under Snappy) |
| Logos Ink | FAHGs7_GvDw |
Hand-drawn Ink Journal logo variants (under Snappy) |
Design import (simple): npx tsx canva-import-design.ts <filePath> <folderId> [--topic T] [--format F] [--title T] — converts PNG→PDF, imports as editable Canva design, moves to folder, leaves metadata comment. Default in generate.sh (fire-and-forget background).
Full PID pipeline: npx tsx canva-pipeline.ts <filePath> <folderId> [options] — the complete measured pipeline. Each step timed independently, non-fatal failures don't block the chain. Used with generate.sh --canva-full or canvaImport(path, {full: true}).
bash# Import only (default in generate.sh)
npx tsx canva-pipeline.ts /tmp/hero.png FAHGryjpN3c --topic "PID Loops" --format "blog-hero"
# Full pipeline: import + resize matrix + export→CDN
npx tsx canva-pipeline.ts /tmp/hero.png FAHGryjpN3c --topic "PID Loops" --format "blog-hero" --all --json
# Autofill brand template with image
npx tsx canva-pipeline.ts /tmp/hero.png FAHGryjpN3c --autofill --template EAHGnYIfdKQ --title "My Title" --subtitle "Sub"
Pipeline steps (with --all):
--autofill path: upload asset → brand template autofill)PID telemetry goes to stderr: step name, pass/fail, milliseconds. JSON output (--json) returns full result with designId, editUrl, resize map, exportUrls, cdnUrl, and step-by-step timings.
Cross-functional entry: canvaImport(filePath, {folder, topic, format, full: true}) from snappy-image/api.ts. Or CLI: npx tsx snappy-image/api.ts canva /tmp/hero.png blog --full --sync.
Resize any design to new dimensions. Creates a new design (original untouched).
bashnpx tsx ~/.claude/skills/snappy-browse/canva-api.ts resize <designId> <width> <height> [title]
canva.resizeDesign(designId, width, height, title?) — async job, auto-polls. Returns { id, urls: { edit_url, view_url } }.
Platform resize matrix (built into canva-pipeline.ts):
| Key | Dimensions | Use |
|---|---|---|
| skool-1x1 | 1080×1080 | Skool / LinkedIn post |
| blog-16x9 | 1920×1080 | Blog hero / YouTube |
| ig-4x5 | 1080×1350 | Instagram / Portrait |
| yt-thumb | 1280×720 | YouTube thumbnail |
Every imported design carries metadata as a Canva comment thread — topic, format, source file, generation date. Both canva-import-design.ts and canva-pipeline.ts add this automatically.
bashnpx tsx ~/.claude/skills/snappy-browse/canva-api.ts comment <designId> "your message"
npx tsx ~/.claude/skills/snappy-browse/canva-api.ts get-comment <designId> <threadId>
canva.createComment(designId, message), canva.replyToComment(designId, threadId, message), canva.getComment(designId, threadId).
Important: Comments and resize work on designs, not raw image assets. The import pipeline converts PNG→PDF→design so comments work. Raw uploadAsset() produces assets (no comments/resize). The Canva API has no "list all comment threads" endpoint — only get-by-ID.
Current scopes: design:meta:read design:content:read design:content:write asset:read asset:write brandtemplate:meta:read brandtemplate:content:read profile:read folder:read folder:write comment:read comment:write. Re-auth via npx tsx canva-oauth.ts.
The Asset-Upload-Metadata header takes JSON with name_base64 — the filename base64-encoded, NOT raw JSON with name_hint. Getting this wrong returns "Invalid upload metadata header". The fix is in canva-api.ts — do not revert to name_hint.
EAHGnYIfdKQ#Ink Journal 1:1 template with Bulk Create fields:
title (text) — main headingsubtitle (text) — secondary linehero_image (image) — transparent illustration placed on cream backgroundThe proven approach for consistent branded images:
canva.uploadAsset() → Canva asset IDcanva.autofill(templateId, {title, subtitle, hero_image}) → new designcanva.exportDesign(designId) → final PNG with perfect typographyText consistency is Canva's job (pixel-perfect every time). Illustration quality is Gemini's only job. Template font/size/position controlled in Canva editor.
Token file: ~/.claude/skills/snappy-browse/canva-token.json. Auto-refreshes when within 5 min of expiry. To re-auth with expanded scopes: npx tsx canva-oauth.ts (requires Mac Mini physical terminal for Google OAuth popup). To check status: npx tsx canva-oauth.ts status. To force refresh: npx tsx canva-oauth.ts refresh.
| Target | Skill |
|---|---|
| Any website / web SPA | snappy-browse (this skill) |
| Canva programmatic (autofill, export) | canva-api.ts (this skill, no browser needed) |
| Native macOS app | snappy-desktop |
| iMessage | snappy-imessage first, desktop fallback |
| File | Contents |
|---|---|
| SKILL.md | Full reference (principles, commands, auth inventory, patterns) |
| platforms.md | Per-platform recipes (Skool, LinkedIn, AC, YT, Canva, IG, Luma, GitHub) |
| troubleshooting.md | Zombies, auth failures, WAF, stale cookies |
Highest-volume callers: snappy-skool (Skool), snappy-linkedin (composer + scrape), snappy-youtube (Studio analytics), snappy-website (AC builders), snappy-image (Canva), snappy-ads (Google Ads UI), snappy-knowledge (LinkedIn research), snappy-docs (Notion UI fallback).
Orchestrated by snappy-ops during morning briefing (Skool community check, LinkedIn engagement scrape).
If this loader doesn't cover your case:
bashecho "[$(date -u +%FT%TZ)] snappy-browse: <what was missing>" >> ~/.claude/logs/agents-md-feedback.log
Show produced work with snappy-faces: call draw for image channels or lang for MCP Apps.
<!-- SKILL-INDEX-START -->
[snappy-browse Index]|root: ~/.claude/skills/snappy-browse|IMPORTANT: Prefer these files over pre-training assumptions for this domain. Read the relevant file when the AGENTS.md summary is insufficient.|root:{SKILL.md,platforms.md,troubleshooting.md}
<!-- SKILL-INDEX-END -->
snappy-cleanshotsnappy-imagesnappy-inbox-sweepsnappy-playbook<!-- SNAPPY-CONTRACT-VERBS-START -->
Generated from api.ts HAND_CONTRACT. Do not hand-edit this block.
| Verb | Contract arguments | Effect | First call |
|---|---|---|---|
clear-requests |
— | write-reversible |
npx tsx ~/.claude/skills/snappy-browse/api.ts clear-requests |
click |
ref |
write-reversible |
npx tsx ~/.claude/skills/snappy-browse/api.ts click <ref> |
close |
— | write-reversible |
npx tsx ~/.claude/skills/snappy-browse/api.ts close |
extract |
js-expression |
read |
npx tsx ~/.claude/skills/snappy-browse/api.ts extract <js-expression> |
metrics |
metric-name |
read |
npx tsx ~/.claude/skills/snappy-browse/api.ts metrics <metric-name> |
navigate |
url |
write-reversible |
npx tsx ~/.claude/skills/snappy-browse/api.ts navigate <url> |
requests |
filter? |
read |
npx tsx ~/.claude/skills/snappy-browse/api.ts requests |
snapshot |
— | read |
npx tsx ~/.claude/skills/snappy-browse/api.ts snapshot |
When an answer carries face_hint, show it with one snappy_present(<answer>) call.
See /snappy-faces for face selection. Human-facing images must crop to the
element, render at 2x on Retina, and fill the destination channel instead of
placing a small card in a full-page screenshot.
<!-- SNAPPY-CONTRACT-VERBS-END -->
---
name: snappy-browse
role: Browser automation primitive via agent-browser CLI (Playwright). Cookie auth, session isolation, semantic locators, bulk JS extraction.
loaded-by: PreToolUse hook (auto-injected when "snappy-browse" is mentioned)
Triggers on: browser automation, agent-browser, Playwright, Skool UI, LinkedIn scrape, web login
---
# snappy-browse -- Agent Loader
You are operating the browser automation primitive for the Snappy stack. Every web interaction (Skool, LinkedIn, Canva, YouTube Studio, Instagram, Luma, GitHub web UI) routes through this skill. ActiveCampaign is NOT in use. The full SKILL.md has platform recipes and troubleshooting; the rules below are load-bearing.
## API module
```typescript
import { runBrowser, navigate, extract, snapshot, click, fill, getUrl, screenshotPage, close } from "../snappy-browse/session.ts";
```
`session.ts` is the browser road and it reads no credential; `api.ts` re-exports every symbol above and adds the Canva Connect client, so importing the session functions from `api.ts` makes your hand declare `CANVA_CLIENT_ID` and `CANVA_CLIENT_SECRET` it will never spend (a requirement is read one hop through an import). Import `session.ts` unless you want Canva.
CLI:
```bash
npx tsx ~/.claude/skills/snappy-browse/api.ts navigate "https://example.com"
npx tsx ~/.claude/skills/snappy-browse/api.ts extract "document.title"
npx tsx ~/.claude/skills/snappy-browse/api.ts snapshot
npx tsx ~/.claude/skills/snappy-browse/api.ts click @e3
npx tsx ~/.claude/skills/snappy-browse/api.ts close
```
## API functions
| Function | Purpose |
|----------|---------|
| `runBrowser(command, args?)` | Run arbitrary agent-browser command with session isolation |
| `navigate(url, statePath?)` | Navigate to URL, optionally loading auth state |
| `extract(expression)` | Extract data via JavaScript eval in browser |
| `snapshot()` | Take interactive snapshot, return element refs |
| `click(ref)` | Click element by ref (e.g. @e3) |
| `fill(ref, value)` | Fill input field by ref |
| `getUrl()` | Get current page URL |
| `screenshotPage(path?, full?)` | Take screenshot |
| `close()` | Close the current session |
## Rules
1. **agent-browser CLI only.** NEVER use Charlotte MCP browser tools. They don't inject cookies and Robert's CLAUDE.md forbids them.
2. **Session isolation first.** Set `AGENT_BROWSER_SESSION` to a unique value before any command:
```bash
export AGENT_BROWSER_SESSION="${AGENT_BROWSER_SESSION:-task-$$-$(date +%s)}"
```
3. **Auth before navigating.** First `open` must include `--state ~/.openclaw/workspace/<platform>-auth.json`. Subsequent commands inherit.
3b. **ALWAYS save state back when done.** Before closing the session, run `agent-browser state save ~/.openclaw/workspace/<platform>-auth.json`. This refreshes the cookies so the next agent doesn't hit expired auth. This is mandatory, not optional.
3c. **Verify URL before saving state.** Before `state save`, always `get url` and confirm you're on the correct platform. Never save state if the browser navigated away from your target platform.
4. **Snapshot before acting.** `agent-browser snapshot -i` to see the page before clicking blind.
5. **Never guess URLs.** Navigate like a human: open the platform, search, click. Use URLs only when given explicitly or extracted from a prior snapshot.
6. **Quote URLs** containing `?` or `&`.
7. **Bulk extract with `eval`.** `agent-browser eval "document.querySelectorAll(...).map(...)"` beats clicking through pages one by one.
8. **`@ref` from snapshot is the default, not semantic text.** `agent-browser snapshot -i` → `click @e3` is the first choice for anything clickable. Semantic text locators (`find text "Submit" click`) are a fallback, not the default — they throw Playwright strict-mode violations when the label appears more than once on the page and the click silently does nothing. See "Critical: strict-mode violations" below.
9. **Cleanup your session only.** `agent-browser --session "$AGENT_BROWSER_SESSION" close`. Never `pkill` the global daemon when concurrent agents run.
10. **Credentials via `snappy-settings/.env.cache`.** Never hardcode passwords.
## Auth state files
Located at `~/.openclaw/workspace/<platform>-auth.json`. Ready: skool, linkedin, instagram, luma, canva, github. On-demand: activecampaign, youtube.
## Auth recovery via CDP attach (when the auth file is empty/expired)
When `<platform>-auth.json` is empty or stale and there's no programmatic login, piggyback on a Chrome window that's **already logged in** via the Chrome DevTools Protocol debug port:
```bash
# 1. Confirm Chrome is listening on CDP (usually 9222)
lsof -i :9222 | head
# 2. Attach — do NOT pass --state, you want the running profile's real session
agent-browser --session "recover-$$" connect 9222
# 3. Verify you landed on the logged-in page (e.g. linkedin.com/feed/)
agent-browser --session "recover-$$" get url
# 4. Save the live session's cookies into the auth file
agent-browser --session "recover-$$" state save ~/.openclaw/workspace/<platform>-auth.json
```
**When to use:** the auth file is 36 bytes (`{"cookies":[],"origins":[]}`), a prior run wiped it, credentials aren't in `.env.cache`, or the platform uses OAuth/2FA that blocks headless login. If Chrome isn't already listening on 9222, start it with `--remote-debugging-port=9222`.
**Critical:** `connect` attaches to the running profile. Do **not** combine with `--state` — that layers a stored context over a live browser and produces empty saves. This is how we recovered LinkedIn auth 2026-04-14 after the fetcher's state-save bug wiped `linkedin-auth.json`.
**Canva requires `--headed` mode.** Headless hits Cloudflare Turnstile with no programmatic bypass. Canva also uses Google OAuth (no email/password flow) so re-auth requires manual headed login: `agent-browser --headed --state ~/.openclaw/workspace/canva-auth.json open "https://www.canva.com/"`, log in via Google popup, then `agent-browser state save ~/.openclaw/workspace/canva-auth.json`.
**Canva auth-liveness check (important).** Canva's logged-out homepage keeps the friendly title `"Canva: Visual Suite for Everyone"` — `document.title` is **not** a valid auth signal and will fool you. Use `canvaAuthLive()` from `api.ts` which checks for a `Log in`/`Sign up` link in the top nav: present = logged out, absent = logged in. Also call `canvaDismissCookieBanner()` after `open` — the "But first, cookies 🍪" banner covers the viewport and blocks clicks. OAuth popup requires a physical GUI session, so if `canvaAuthLive()` returns `{live:false}`, stop and ask Robert to re-auth on the Mac Mini — Playwright from SSH cannot drive the Google popup.
## Key commands
```bash
agent-browser --state <auth.json> open "<url>" # Launch with cookies
agent-browser snapshot -i # Interactive elements
agent-browser click @e3 # By ref
agent-browser fill @e5 "value" # Clear + fill
agent-browser find text "X" click # Semantic
agent-browser eval "JS_EXPRESSION" # Extract data
agent-browser get url # Verify navigation
agent-browser wait 2000 # Wait ms
agent-browser screenshot [path] # Capture
```
## Critical: session persistence
Shell env resets between Bash calls. `AGENT_BROWSER_SESSION` exports are lost. Chain all commands in a single Bash call: `agent-browser --state ... open URL && sleep 3 && agent-browser eval "..."`. The daemon persists but your env var does not.
## Critical: strict-mode violations (silent click failures)
Playwright runs in strict mode. A locator that matches **more than one element** throws `strict mode violation: getByText('X') resolved to N elements` and the action silently fails. Seen 58× in recent logs on snappy.ai pages where headings AND body copy share the same word (`"Build and control..."` + `"Control: keep agents..."` both match `getByText("Control")`).
**Selector priority — use the first one that works:**
1. **`@ref` from `snapshot -i`** — always unique, always preferred. Re-snapshot if stale.
2. **Role + accessible name** — `agent-browser find role button name "Sign In" click`. Role scopes the match to actual buttons, not surrounding prose.
3. **Text with `--exact`** — `find text "Sign In" --exact click`. Only when the label is guaranteed unique on the page.
4. **Scoped snapshot** — `snapshot -i -s "#sidebar"` or `-s "[class*='Nav']"`, then click the ref from the scoped result.
5. **Partial-class CSS** — `click "[class*=\"PrimaryButton\"]"`. Class hashes rotate but prefixes don't.
6. **`:nth-match` / index** — `find text "Control" --nth 0 click`. Last resort; order changes break you.
**Rules:**
- **Never pass raw ambiguous text to `click` or `find text`.** If the label could appear in headings, nav, and body copy, use `snapshot -i` + `@ref`.
- **When a click does nothing, assume strict-mode violation before assuming the element is missing.** Re-run `snapshot -i -C` and grep the output for the label. Count > 1 means disambiguate.
- **Prefer `snapshot -i` → `click @eN` for anything past a dead-simple landing page.** Semantic locators are a fallback, not the default (this overrides older guidance in the platforms and troubleshooting docs).
- **Long-term fix for sites we own** (snappy.ai, skills.snappy.ai, classroom pages): add `data-testid` to the clickable element. Until then, playbook above holds.
## Critical: the platform's Featured thumbnail-swap flow
Attaching a custom thumbnail to a Featured section item (profile → Featured → edit pencil) uses a nested two-pencil pattern with a hidden file input. Rediscovered the hard way (2026-04-11) — not obvious from a snapshot.
- **Per-item `Edit` is an `<a>`, not a `<button>`.** `querySelectorAll('button')` misses it. Use `snapshot -i` + `@ref`, or role-scope: `find role link name "Edit" click`.
- **The thumbnail pencil button inside the edit modal uses `aria-label="Edit media"`** — same label as the modal header. Scope to button-only: `find role button name "Edit media" click`, not semantic text.
- **The `input[type=file]` is hidden and only populates after clicking the pencil.** A stale `querySelectorAll('input[type=file]')` before the click returns empty. Click the pencil first, then query the input.
- **Upload is async** — several seconds between `upload` and preview swap in the modal. Screenshot before and after to confirm.
- **No cropper exists.** The platform auto-center-crops on render. Landscape aspect (1.91:1 / 16:9) is still preferred over square, but don't expect pixel-exact framing.
- **Verify via CDN media-key change, not a toast.** After Save, full reload the profile and read the rendered `<img>` src on the Featured card. Compare the media key (`D4E2DAQH...`) to the pre-edit value. Same-session toasts are not evidence — see the verification-certificate rule in `skill-spec.md`.
- **Media items vs Link items click through differently.** Media items open the platform's own image viewer — they do NOT route to external URLs even if the title/description contains one. If click-through to an external URL is required, the item must be a Link type (add via "Add featured" → "Add a link"). Whether Link items also support the thumbnail-swap flow above is TBD as of 2026-04-11; probe with one test item before bulk-rebuilding.
Full recipe (selectors, timings, screenshot checklist) lives in `platforms.md` under the LinkedIn section. This loader entry exists so the next agent doesn't burn an hour rediscovering the nested-pencil pattern.
## Critical: never use `get text body` on heavy pages
Pages like Skool feeds return 270KB+ from `get text body`. Always use `agent-browser eval` with targeted DOM selectors instead. For Skool community feeds, the working selector chain is:
```bash
agent-browser eval "JSON.stringify(Array.from(document.querySelectorAll('[class*=\"PostItemContentWrapper\"]')).map(el => {const lines = el.innerText.split('\n').filter(l => l.trim()); return {likes: lines[0], author: lines[1], date: lines[2], category: lines[3], title: lines[4], preview: lines.slice(5).join(' ').substring(0,120), comments: el.closest('[class*=\"PostItemWrapper\"]')?.querySelector('[class*=\"CommentsCount\"]')?.textContent?.trim() || '0'}}))"
```
## Canva Connect API (preferred over browser for Canva)
Browser automation of Canva hits Cloudflare Turnstile and requires `--headed` mode. For programmatic Canva work (autofill, export, asset management), use the **Canva Connect API** via `canva-api.ts` instead.
```typescript
import { canva } from "../snappy-browse/canva-api.ts";
```
CLI:
```bash
npx tsx ~/.claude/skills/snappy-browse/canva-api.ts templates # list brand templates
npx tsx ~/.claude/skills/snappy-browse/canva-api.ts dataset <id> # show autofill fields
npx tsx ~/.claude/skills/snappy-browse/canva-api.ts upload <file> # upload image asset
npx tsx ~/.claude/skills/snappy-browse/canva-api.ts autofill <id> '<json>' [title]
npx tsx ~/.claude/skills/snappy-browse/canva-api.ts export <designId> [png|pdf|jpg]
npx tsx ~/.claude/skills/snappy-browse/canva-api.ts designs # list recent designs
npx tsx ~/.claude/skills/snappy-browse/canva-api.ts folders [parentId] # list folders (default: root)
npx tsx ~/.claude/skills/snappy-browse/canva-api.ts folder-items <id> # list items in folder
npx tsx ~/.claude/skills/snappy-browse/canva-api.ts create-folder <name> [parentId]
npx tsx ~/.claude/skills/snappy-browse/canva-api.ts move <itemId> <toFolderId>
npx tsx ~/.claude/skills/snappy-browse/canva-api.ts scan # overview of root items
npx tsx ~/.claude/skills/snappy-browse/canva-api.ts import <file> [title] # import image as editable design (PNG→PDF→design)
npx tsx ~/.claude/skills/snappy-browse/canva-api.ts import-url <url> [title] # import design from URL
```
**Key API functions:** `canva.listTemplates()`, `canva.getDataset(id)`, `canva.uploadAsset(path)`, `canva.autofill(templateId, data, title)`, `canva.exportDesign(designId, format)`, `canva.listDesigns()`, `canva.createDesign()`, `canva.getDesign(id)`, `canva.createFolder(name, parentId)`, `canva.listAllFolderItems(folderId)`, `canva.moveItem(itemId, toFolderId)`, `canva.deleteFolder(id)`, `canva.importDesign(filePath, title)`, `canva.importDesignFromUrl(url, title)`, `canva.resizeDesign(designId, w, h, title)`, `canva.createComment(designId, message)`, `canva.replyToComment(designId, threadId, message)`.
### Canva folder map (as of 2026-04-12)
| Folder | ID | Purpose |
|---|---|---|
| Snappy | `FAHGr-3HtNU` | Current brand/business work |
| Clients | `FAHGrwJVH4w` | Client delivery designs |
| Amazon Era | `FAHGrxHZD-4` | Legacy Amazon/Amzsite designs |
| The Orange Brand | `FAHGr9xoF2g` | TOB/HealthySeller brand |
| Personal | `FAHGr3Vstes` | Resumes, personal docs |
| Archive | `FAHGr40cMVQ` | Untitled/old/junk designs |
| Base Ref Templates | `FAHGr065Z-E` | PIL-generated grounding refs for image system |
| Generated Images | `FAHGryjpN3c` | Auto-uploaded outputs from generate.sh --canva |
| Course Images | `FAHGsRxfKoU` | Lesson/course imagery (under Snappy) |
| YouTube Thumbnails | `FAHGsUIkggM` | A/B thumbnail variants (under Snappy) |
| Blog Heroes | `FAHGsVN6qK4` | Blog post hero images (under Snappy) |
| LinkedIn Featured | `FAHGsTUK1S0` | Featured section thumbnails (under Snappy) |
| Logos Flat | `FAHGszPSUR4` | Black-on-transparent flat logos (under Snappy) |
| Logos Ink | `FAHGs7_GvDw` | Hand-drawn Ink Journal logo variants (under Snappy) |
**Design import (simple):** `npx tsx canva-import-design.ts <filePath> <folderId> [--topic T] [--format F] [--title T]` — converts PNG→PDF, imports as editable Canva design, moves to folder, leaves metadata comment. Default in `generate.sh` (fire-and-forget background).
**Full PID pipeline:** `npx tsx canva-pipeline.ts <filePath> <folderId> [options]` — the complete measured pipeline. Each step timed independently, non-fatal failures don't block the chain. Used with `generate.sh --canva-full` or `canvaImport(path, {full: true})`.
```bash
# Import only (default in generate.sh)
npx tsx canva-pipeline.ts /tmp/hero.png FAHGryjpN3c --topic "PID Loops" --format "blog-hero"
# Full pipeline: import + resize matrix + export→CDN
npx tsx canva-pipeline.ts /tmp/hero.png FAHGryjpN3c --topic "PID Loops" --format "blog-hero" --all --json
# Autofill brand template with image
npx tsx canva-pipeline.ts /tmp/hero.png FAHGryjpN3c --autofill --template EAHGnYIfdKQ --title "My Title" --subtitle "Sub"
```
Pipeline steps (with `--all`):
1. **PNG→PDF→import** (or `--autofill` path: upload asset → brand template autofill)
2. **Move to folder**
3. **Metadata comment** (topic, format, source, date)
4. **Resize matrix** — 4 platform variants: Skool 1080×1080, Blog 1920×1080, IG 1080×1350, YT 1280×720
5. **Export→CDN** — export PNG from Canva, push to DO Spaces
PID telemetry goes to stderr: step name, pass/fail, milliseconds. JSON output (`--json`) returns full result with designId, editUrl, resize map, exportUrls, cdnUrl, and step-by-step timings.
**Cross-functional entry:** `canvaImport(filePath, {folder, topic, format, full: true})` from `snappy-image/api.ts`. Or CLI: `npx tsx snappy-image/api.ts canva /tmp/hero.png blog --full --sync`.
### Design resize
Resize any design to new dimensions. Creates a new design (original untouched).
```bash
npx tsx ~/.claude/skills/snappy-browse/canva-api.ts resize <designId> <width> <height> [title]
```
`canva.resizeDesign(designId, width, height, title?)` — async job, auto-polls. Returns `{ id, urls: { edit_url, view_url } }`.
Platform resize matrix (built into canva-pipeline.ts):
| Key | Dimensions | Use |
|-----|-----------|-----|
| skool-1x1 | 1080×1080 | Skool / LinkedIn post |
| blog-16x9 | 1920×1080 | Blog hero / YouTube |
| ig-4x5 | 1080×1350 | Instagram / Portrait |
| yt-thumb | 1280×720 | YouTube thumbnail |
### Comments as context
Every imported design carries metadata as a Canva comment thread — topic, format, source file, generation date. Both `canva-import-design.ts` and `canva-pipeline.ts` add this automatically.
```bash
npx tsx ~/.claude/skills/snappy-browse/canva-api.ts comment <designId> "your message"
npx tsx ~/.claude/skills/snappy-browse/canva-api.ts get-comment <designId> <threadId>
```
`canva.createComment(designId, message)`, `canva.replyToComment(designId, threadId, message)`, `canva.getComment(designId, threadId)`.
**Important:** Comments and resize work on **designs**, not raw image assets. The import pipeline converts PNG→PDF→design so comments work. Raw `uploadAsset()` produces assets (no comments/resize). The Canva API has no "list all comment threads" endpoint — only get-by-ID.
### OAuth scopes (as of 2026-04-12)
Current scopes: `design:meta:read design:content:read design:content:write asset:read asset:write brandtemplate:meta:read brandtemplate:content:read profile:read folder:read folder:write comment:read comment:write`. Re-auth via `npx tsx canva-oauth.ts`.
### Asset upload format (load-bearing)
The `Asset-Upload-Metadata` header takes JSON with `name_base64` — the filename **base64-encoded**, NOT raw JSON with `name_hint`. Getting this wrong returns `"Invalid upload metadata header"`. The fix is in `canva-api.ts` — do not revert to `name_hint`.
### Brand template: `EAHGnYIfdKQ`
Ink Journal 1:1 template with Bulk Create fields:
- `title` (text) — main heading
- `subtitle` (text) — secondary line
- `hero_image` (image) — transparent illustration placed on cream background
### Hybrid pipeline (Gemini + Canva)
The proven approach for consistent branded images:
1. **Gemini** generates ONLY the illustration (no text, no border, white background)
2. **PIL** removes white background → transparent PNG
3. `canva.uploadAsset()` → Canva asset ID
4. `canva.autofill(templateId, {title, subtitle, hero_image})` → new design
5. `canva.exportDesign(designId)` → final PNG with perfect typography
Text consistency is Canva's job (pixel-perfect every time). Illustration quality is Gemini's only job. Template font/size/position controlled in Canva editor.
### OAuth token management
Token file: `~/.claude/skills/snappy-browse/canva-token.json`. Auto-refreshes when within 5 min of expiry. To re-auth with expanded scopes: `npx tsx canva-oauth.ts` (requires Mac Mini physical terminal for Google OAuth popup). To check status: `npx tsx canva-oauth.ts status`. To force refresh: `npx tsx canva-oauth.ts refresh`.
## Routing
| Target | Skill |
|---|---|
| Any website / web SPA | snappy-browse (this skill) |
| Canva programmatic (autofill, export) | `canva-api.ts` (this skill, no browser needed) |
| Native macOS app | snappy-desktop |
| iMessage | snappy-imessage first, desktop fallback |
## Skill files
| File | Contents |
|---|---|
| SKILL.md | Full reference (principles, commands, auth inventory, patterns) |
| platforms.md | Per-platform recipes (Skool, LinkedIn, AC, YT, Canva, IG, Luma, GitHub) |
| troubleshooting.md | Zombies, auth failures, WAF, stale cookies |
## Consumers
Highest-volume callers: snappy-skool (Skool), snappy-linkedin (composer + scrape), snappy-youtube (Studio analytics), snappy-website (AC builders), snappy-image (Canva), snappy-ads (Google Ads UI), snappy-knowledge (LinkedIn research), snappy-docs (Notion UI fallback).
Orchestrated by snappy-ops during morning briefing (Skool community check, LinkedIn engagement scrape).
---
If this loader doesn't cover your case:
```bash
echo "[$(date -u +%FT%TZ)] snappy-browse: <what was missing>" >> ~/.claude/logs/agents-md-feedback.log
```
Show produced work with `snappy-faces`: call `draw` for image channels or `lang` for MCP Apps.
<!-- SKILL-INDEX-START -->
[snappy-browse Index]|root: ~/.claude/skills/snappy-browse|IMPORTANT: Prefer these files over pre-training assumptions for this domain. Read the relevant file when the AGENTS.md summary is insufficient.|root:{SKILL.md,platforms.md,troubleshooting.md}
<!-- SKILL-INDEX-END -->
## Used by
- `snappy-cleanshot`
- `snappy-image`
- `snappy-inbox-sweep`
- `snappy-playbook`
<!-- SNAPPY-CONTRACT-VERBS-START -->
## Contract verbs
Generated from `api.ts` `HAND_CONTRACT`. Do not hand-edit this block.
| Verb | Contract arguments | Effect | First call |
|---|---|---|---|
| `clear-requests` | — | `write-reversible` | `npx tsx ~/.claude/skills/snappy-browse/api.ts clear-requests` |
| `click` | `ref` | `write-reversible` | `npx tsx ~/.claude/skills/snappy-browse/api.ts click <ref>` |
| `close` | — | `write-reversible` | `npx tsx ~/.claude/skills/snappy-browse/api.ts close` |
| `extract` | `js-expression` | `read` | `npx tsx ~/.claude/skills/snappy-browse/api.ts extract <js-expression>` |
| `metrics` | `metric-name` | `read` | `npx tsx ~/.claude/skills/snappy-browse/api.ts metrics <metric-name>` |
| `navigate` | `url` | `write-reversible` | `npx tsx ~/.claude/skills/snappy-browse/api.ts navigate <url>` |
| `requests` | `filter?` | `read` | `npx tsx ~/.claude/skills/snappy-browse/api.ts requests` |
| `snapshot` | — | `read` | `npx tsx ~/.claude/skills/snappy-browse/api.ts snapshot` |
## Show the result
When an answer carries `face_hint`, show it with one `snappy_present(<answer>)` call.
See `/snappy-faces` for face selection. Human-facing images must crop to the
element, render at 2x on Retina, and fill the destination channel instead of
placing a small card in a full-page screenshot.
<!-- SNAPPY-CONTRACT-VERBS-END -->