← All Skills

snappy-browse

v1.0.0
16 files, 150.7 KB ~5,592 words · 23 min read Updated 2026-09-09

snappy-browse skill

37 of 45 checks pass
What it can do
clear-requestswrite-reversible
click refwrite-reversible
closewrite-reversible
extract js-expressionread
metrics metric-nameread
navigate urlwrite-reversible
requests filter?read
snapshotread
What does not pass yet
$ npx snappy-skills install snappy-browse
zip ↓
Documents
AGENTS.md

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#

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

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)}"
  1. 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.

  1. Snapshot before acting. agent-browser snapshot -i to see the page before clicking blind.
  2. Never guess URLs. Navigate like a human: open the platform, search, click. Use URLs only when given explicitly or extracted from a prior snapshot.
  3. Quote URLs containing ? or &.
  4. Bulk extract with eval. agent-browser eval "document.querySelectorAll(...).map(...)" beats clicking through pages one by one.
  5. @ref from snapshot is the default, not semantic text. agent-browser snapshot -iclick @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.
  6. Cleanup your session only. agent-browser --session "$AGENT_BROWSER_SESSION" close. Never pkill the global daemon when concurrent agents run.
  7. 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#

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

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 nameagent-browser find role button name "Sign In" click. Role scopes the match to actual buttons, not surrounding prose.
  3. Text with --exactfind text "Sign In" --exact click. Only when the label is guaranteed unique on the page.
  4. Scoped snapshotsnapshot -i -s "#sidebar" or -s "[class*='Nav']", then click the ref from the scoped result.
  5. Partial-class CSSclick "[class*=\"PrimaryButton\"]". Class hashes rotate but prefixes don't.
  6. :nth-match / indexfind 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 -iclick @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.

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:

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'}}))"

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.

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).

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).

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

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.

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.

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:

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 -->

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 -->

---
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 -->

Keyboard Shortcuts

Search in document⌘K
Focus search/
Previous file tab
Next file tab
Close overlayEsc
Show shortcuts?