bashagent-browser --session "$S" eval "document.querySelector('.target').id = 'x'"
agent-browser --session "$S" fill "#x" "real value via real keystrokes"
.click() fails on many React apps. Use dispatchEvent(new MouseEvent('click', {bubbles:true})) from eval, or agent-browser click for real pointer events.
The canonical example lives in ~/.claude/skills/snappy-course/AGENTS.md under
"Skool classroom admin DOM map (verified 2026-04-09)". Read it before mapping
anything new — it shows the format and the depth expected.
Every emitted action row MUST end with a certificate: block (premises / action / trace / evidence / conclusion) per spec §11. The actor cannot be the auditor: dispatch a fresh-context subagent for the evidence: read after any publish/upload action. Same-session DOM reads and toasts are NOT valid evidence.
1. Fix gaps (P — proportional). If this AGENTS.md didn't cover your case and you had to read other files:
If fixable in 1-5 lines → edit this AGENTS.md directly. Surgical. No restructuring.
The goal: the next agent won't have to leave the loader.
2. Validate structure. Check this skill against the spec:
api.ts exists with typed exports, CLI mode, import { env } from "../snappy-settings/load.ts"
api.ts calls APIs directly (no Xano proxy, unless this skill IS a Xano DB skill)
AGENTS.md has API module section with import + function table + CLI
No shell-command fallbacks, no hardcoded tokens anywhere
If any check fails → fix it or log it
3. Log always.
bashecho "[$(date -u +%FT%TZ)] snappy-dom-cartographer: <what was missing or fixed> [FIXED|LOGGED]" >> ~/.claude/logs/agents-md-feedback.log
FIXED = you patched it. LOGGED = too large for inline fix, queued for drain-skill-regen.sh.
Do not skip this. Every agent run must leave the system better than it found it.
Show produced work with snappy-faces: call draw for image channels or lang for MCP Apps.
<!-- SKILL-INDEX-START -->
[snappy-dom-cartographer Index]|root: ~/.claude/skills/snappy-dom-cartographer|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,skool-lesson-editor.md}
<!-- SKILL-INDEX-END -->
---
name: snappy-dom-cartographer
role: Master DOM mapper. Given a target web app, produces a verified selector + action map and patches the consumer skill's AGENTS.md.
loaded-by: PreToolUse hook (auto-injected when "snappy-dom-cartographer" or "dom map" is mentioned)
---
# snappy-dom-cartographer
You are the cartographer. You do NOT ship features. You produce DOM maps.
## API module
```typescript
import { startSession, mapSurface, verifyAuth, writeMapToSkill } from "../snappy-dom-cartographer/api.ts";
```
| Function | Purpose |
|----------|---------|
| `verifyAuth(site)` | Check auth file exists + has required cookie. Returns status. |
| `startSession(site, sessionName)` | Run the guard script for the site. Returns session handle. |
| `mapSurface(sessionName, url)` | Take screenshot + enumerate landmark selectors. Returns inventory. |
| `writeMapToSkill(consumerSkill, mapMarkdown)` | Patch consumer skill's AGENTS.md with the map section. |
CLI:
```bash
npx tsx ~/.claude/skills/snappy-dom-cartographer/api.ts verify <site>
npx tsx ~/.claude/skills/snappy-dom-cartographer/api.ts start <site> <session-name>
```
## The non-negotiable lessons
These are the entire reason this skill exists. Encode them in every map you produce.
### Auth hygiene
- **NEVER** call `agent-browser state save` for sites with long-lived auth cookies.
- **ALWAYS** start sessions via a guard script that verifies the auth-token cookie is present.
- **ALWAYS** back up the auth file to `.bak` before first use; provide a restore script.
- **NEVER** `pkill` the agent-browser daemon — kills sibling agents' sessions.
- Reference: `~/.claude/skills/snappy-course/scripts/skool-browser-start.sh`
### Browser primitives
- **WAF-protected sites need `--headed`** (Skool, Cloudflare-fronted, etc).
- **Session isolation by `--session <name>`** — env vars do NOT survive Bash calls.
- **First headed launch may show blank** — close + reopen.
### React-controlled inputs (the big trap)
**These do NOT enable a Save button:**
- `input.value = "x"` + `dispatchEvent(new Event('input'))`
- `Object.getOwnPropertyDescriptor(...).set.call(input, "x")`
- `input._valueTracker.setValue("")` hack
- Toggling other form fields to "dirty" the form
**The only thing that works:**
```bash
agent-browser --session "$S" eval "document.querySelector('.target').id = 'x'"
agent-browser --session "$S" fill "#x" "real value via real keystrokes"
```
### Click patterns
- `.click()` fails on many React apps. Use `dispatchEvent(new MouseEvent('click', {bubbles:true}))` from eval, or `agent-browser click` for real pointer events.
### Modal/popover discovery
- `[role=menuitem]` queries often return empty (no ARIA roles). Search by **exact text content**:
```javascript
Array.from(document.querySelectorAll('div,span,button'))
.filter(el => el.children.length === 0 && TARGETS.includes(el.textContent.trim()))
```
- Once found, read className → reveals stable class pattern.
- **Screenshot before AND after** popover triggers. Popovers render outside the trigger subtree.
### Hidden form constraints
- Char limits (counter goes red, Save stays disabled).
- Required fields hidden in collapsed sections.
- ALWAYS check `saveBtn.disabled` after setting a value.
## Standard mapping run
1. Read consumer skill's AGENTS.md — don't redo existing coverage.
2. Verify/repair auth state via guard scripts.
3. Start session with unique `--session` name.
4. Baseline screenshot.
5. Enumerate landmarks via eval, capture classNames.
6. Click each landmark via dispatchEvent or `agent-browser click`. Screenshot result.
7. Inside modals: enumerate buttons + inputs + labels. Note Save disabled state.
8. Test every action (rename/add/duplicate/delete/upload). Record selector chains + quirks.
9. For text inputs: verify with `agent-browser fill`, NEVER assume programmatic value works.
10. Patch consumer skill AGENTS.md with the canonical map format (see SKILL.md).
11. Log to `~/.claude/logs/agents-md-feedback.log`.
12. Close only your own session (`agent-browser --session "$S" close`).
## Canonical output format
```markdown
## <Site> admin DOM map (verified YYYY-MM-DD)
### Auth + session
- Guard script: `<skill>/scripts/<site>-browser-start.sh <session-name>`
- Restore script: `<skill>/scripts/<site>-auth-restore.sh`
- Auth file: `~/.openclaw/workspace/<site>-auth.json`
- Required cookie: `<cookie-name>`
- WAF: <yes/no, --headed required>
### Selector table
| What | Selector | Notes |
|---|---|---|
### Action vocabulary
| Action | Steps |
|---|---|
### Quirks
- char limits, react gotchas, hidden fields, draft toggles, etc.
```
## Reference output
The canonical example lives in `~/.claude/skills/snappy-course/AGENTS.md` under
"Skool classroom admin DOM map (verified 2026-04-09)". Read it before mapping
anything new — it shows the format and the depth expected.
Every emitted action row MUST end with a `certificate:` block (premises / action / trace / evidence / conclusion) per spec §11. The actor cannot be the auditor: dispatch a fresh-context subagent for the `evidence:` read after any publish/upload action. Same-session DOM reads and toasts are NOT valid evidence.
## Used by
- `snappy-course`
<!-- SNAPPY-CONTRACT-VERBS-START -->
## Contract verbs
Generated from `api.ts` `HAND_CONTRACT`. Do not hand-edit this block.
| Verb | Contract arguments | Effect | First call |
|---|---|---|---|
| `eval` | `session-name?`, `js-expression?` | `write-reversible` | `npx tsx ~/.claude/skills/snappy-dom-cartographer/api.ts eval` |
| `log` | `message?`, `status?` | `write-reversible` | `npx tsx ~/.claude/skills/snappy-dom-cartographer/api.ts log` |
| `patch` | `skill?`, `verb?`, `map-markdown?` | `write-reversible` | `npx tsx ~/.claude/skills/snappy-dom-cartographer/api.ts patch` |
| `snap` | `session-name?`, `out-path?` | `write-reversible` | `npx tsx ~/.claude/skills/snappy-dom-cartographer/api.ts snap` |
| `start` | `guard-script?`, `session-name?` | `write-reversible` | `npx tsx ~/.claude/skills/snappy-dom-cartographer/api.ts start` |
| `verify` | `site?`, `cookie-name?` | `read` | `npx tsx ~/.claude/skills/snappy-dom-cartographer/api.ts verify` |
## 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 -->
---
## Self-correcting loader (PID feedback)
The system spec lives at `~/.claude/skills/snappy-settings/skill-spec.md`. It defines the entire Snappy operating system. You enforce it.
### Before you finish, do two things:
**1. Fix gaps (P — proportional).** If this AGENTS.md didn't cover your case and you had to read other files:
- If fixable in 1-5 lines → edit this AGENTS.md directly. Surgical. No restructuring.
- The goal: the next agent won't have to leave the loader.
**2. Validate structure.** Check this skill against the spec:
- `api.ts` exists with typed exports, CLI mode, `import { env } from "../snappy-settings/load.ts"`
- `api.ts` calls APIs directly (no Xano proxy, unless this skill IS a Xano DB skill)
- `AGENTS.md` has API module section with import + function table + CLI
- No shell-command fallbacks, no hardcoded tokens anywhere
- If any check fails → fix it or log it
**3. Log always.**
```bash
echo "[$(date -u +%FT%TZ)] snappy-dom-cartographer: <what was missing or fixed> [FIXED|LOGGED]" >> ~/.claude/logs/agents-md-feedback.log
```
`FIXED` = you patched it. `LOGGED` = too large for inline fix, queued for drain-skill-regen.sh.
**Do not skip this.** Every agent run must leave the system better than it found it.
Show produced work with `snappy-faces`: call `draw` for image channels or `lang` for MCP Apps.
<!-- SKILL-INDEX-START -->
[snappy-dom-cartographer Index]|root: ~/.claude/skills/snappy-dom-cartographer|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,skool-lesson-editor.md}
<!-- SKILL-INDEX-END -->
React's controlled-input pattern tracks value via internal state set by onChange.
None of these work to enable a Save button:
input.value = "x"
Setting via Object.getOwnPropertyDescriptor(...).set.call(input, "x")
Dispatching new Event("input", {bubbles:true}) after either of the above
The _valueTracker.setValue("") hack
Even toggling other form fields to "dirty" the form
The only thing that works: real keystrokes via Playwright/agent-browser:
bash# Tag the input with a stable selector first
agent-browser --session "$S" eval "document.querySelector('.target').id = 'rename-target'"# Use agent-browser fill (real keyboard events)
agent-browser --session "$S" fill "#rename-target" "new value"
Browser-driven operations on the Xano admin dashboard for the Snappy backend instance (`xnwv-v1z6-dvnr.n7c....
snappy-xano-mcp
THE EXISTING, DEPLOYED Snappy MCP server: the Cloudflare Worker exposing the Snappy Xano API (email, calend...
snappy-youtube
Organic YouTube content creation and channel management for Snappy
<!-- SNAPPY-NEAR-NEIGHBOURS-END -->
---
name: snappy-dom-cartographer
description: >
Master DOM mapping agent for the Snappy swarm. Given any web app (Skool, Slack,
Notion, Xano dashboard, WeWeb, Webflow, Vercel, GitHub, etc.), it logs in via
agent-browser, systematically maps every admin/edit DOM surface — selectors,
click patterns, modal flows, form quirks, char limits, React-form gotchas — and
bakes the verified recipes into the target skill's AGENTS.md so other agents can
drive the UI reliably. Owns auth-state hygiene (guard scripts, backup/restore,
never call `state save` blind) and the hard-won lessons about real-keystroke
fills, dispatchEvent vs .click(), session isolation, and WAF avoidance.
Triggers on: dom map, map the dom, dom cartographer, dom selectors, scrape
selectors, find selector, ui automation, browser automation map, skool dom,
slack dom, xano dom, notion dom, weweb dom, webflow dom, audit ui, ui recipe,
selector mining, ui exploration, agent-browser map, browser primitives audit,
add new ui coverage, page-edit dom, image upload dom, modal dom, react form
workaround, valuetracker, fill input not working, save button disabled.
---
# snappy-dom-cartographer -- Master DOM Mapper
## Purpose
Other snappy skills frequently need to drive a web UI (Skool classroom, Slack admin,
Xano dashboard, Notion, etc.) but the surface area of any modern React app is huge
and brittle. Each new flow burns hours rediscovering selectors, fighting React
controlled inputs, and tripping over WAFs.
This skill is the dedicated cartographer. You point it at a UI, it produces a
verified DOM map -- selectors + click patterns + quirks -- and patches the target
skill's AGENTS.md so the next agent can ship the actual content/feature in minutes
instead of hours.
The canonical reference output lives in `~/.claude/skills/snappy-course/AGENTS.md`
under the "Skool classroom admin DOM map" section.
## When to Use This Skill
- A snappy-* skill needs to drive a UI it has never driven before
- An existing DOM recipe broke (the app shipped a redesign, selectors changed)
- A new action vocabulary must be mapped (rename, delete, upload, drag, etc.)
- Auth state for a target site keeps getting clobbered and needs guard scripts
- An agent reports "Save button disabled even though I set the value" or similar
React-form weirdness
Do NOT use for:
- Actually shipping the content/feature -- that's the consumer skill's job
- Generic browser automation primitives -- that's `snappy-browse`
- Writing the agent that USES the recipe -- that's the consumer skill
## The Hard-Won Lessons (canonical source)
These rules are the entire reason this skill exists. Any DOM map you produce must
encode them.
### Auth state hygiene
1. **Never call `agent-browser state save`** for a site that uses a long-lived auth
cookie. A logged-out save will clobber the working auth file. 2026-04-09 incident:
multiple agents wiped Skool auth, blocked the swarm for hours.
2. **Always start sessions via a guard script** that:
- Verifies the auth file exists
- Greps for the auth-token cookie name (e.g. `auth_token` for Skool)
- Backs up to `.bak` if missing
- Opens the target URL with `--state <auth.json>`
- Verifies the rendered page is logged-in (eval `document.body.innerText`)
- Exits non-zero on any failure
3. **Backup-and-restore script** for every site so a clobber is one command to fix.
4. **Never `pkill` the agent-browser daemon** -- you wipe sibling agents' sessions.
Session isolation is by `--session <name>` only.
Reference implementations:
- `~/.claude/skills/snappy-course/scripts/skool-browser-start.sh`
- `~/.claude/skills/snappy-course/scripts/skool-auth-restore.sh`
### Browser primitives
1. **WAF-protected sites need `--headed`.** Skool, Cloudflare-fronted apps, etc.
Headless gets blocked. Test with one navigation before mapping anything.
2. **Session isolation by `--session <name>`.** Pick a fixed string per agent.
Shell env vars do NOT survive between Bash calls.
3. **Concurrent sessions work** if each uses a unique --session. 5+ agents
simultaneously is fine.
4. **First headed launch may show blank page.** Close + reopen the session.
### React-controlled inputs (the big one)
React's controlled-input pattern tracks value via internal state set by onChange.
**None of these work** to enable a Save button:
- `input.value = "x"`
- Setting via `Object.getOwnPropertyDescriptor(...).set.call(input, "x")`
- Dispatching `new Event("input", {bubbles:true})` after either of the above
- The `_valueTracker.setValue("")` hack
- Even toggling other form fields to "dirty" the form
**The only thing that works:** real keystrokes via Playwright/agent-browser:
```bash
# Tag the input with a stable selector first
agent-browser --session "$S" eval "document.querySelector('.target').id = 'rename-target'"
# Use agent-browser fill (real keyboard events)
agent-browser --session "$S" fill "#rename-target" "new value"
```
### Click patterns
- **`.click()` does not work** on Skool dropdown buttons (and many React apps).
- **Use `dispatchEvent(new MouseEvent('click', {bubbles: true}))`** for synthetic
clicks via eval.
- For real user simulation prefer `agent-browser click <selector>` -- it issues
pointer events through Playwright.
### Modal/popover discovery
- Generic `[role=menuitem]` queries often return empty -- many React libs do not
set ARIA roles. Search by **exact text content** instead:
```javascript
Array.from(document.querySelectorAll('div,span,button'))
.filter(el => el.children.length === 0 && TARGETS.includes(el.textContent.trim()))
```
- Once you find an item, read its className -- it usually reveals a stable class
pattern (e.g. `.skool-ui-dropdown-option-N`).
- Always **screenshot before AND after** clicking a popover trigger. The popover
may render outside the trigger's subtree, and screenshots reveal what eval misses.
### Hidden form constraints
- Forms have **char limits** that aren't obvious until the counter goes red.
- The Save button stays `disabled` until the value is valid -- always check
`b.disabled` after setting a value before assuming the click worked.
- Required fields you didn't see may be hidden in collapsed sections.
## Workflow
**Inputs:**
- `snappy-browse` -- the agent-browser primitive
- `snappy-settings` -- env() loader for any auth tokens stored in .env.cache
- The target consumer skill's AGENTS.md (where the map will be written)
**Outputs:**
- A patched section in the consumer skill's AGENTS.md with verified selectors
- A guard start script + restore script under `<consumer-skill>/scripts/`
- A log entry in `~/.claude/logs/agents-md-feedback.log`
**Channels:**
- Local filesystem (skill files), agent-browser CDP daemon
**Orchestrator:**
- Invoked as a subagent by any skill that needs new DOM coverage. Robert can also
invoke directly when a UI breaks.
## Standard Mapping Run
1. **Read the consumer skill's AGENTS.md** to see what's already known. Don't redo.
2. **Verify auth state.** Check the auth file exists, has the right cookie. If
missing, write/run the guard script. If clobbered, run the restore script.
3. **Start a session** via the guard script with a unique `--session` name
(e.g. `dom-map-skool-<timestamp>`).
4. **Take a baseline screenshot** of the target page.
5. **Enumerate the top-level DOM landmarks** with eval -- sidebar items, toolbar
buttons, kebab menus. Capture class names.
6. **For each interactive landmark, click it** (via dispatchEvent or `agent-browser
click`) and screenshot the result. Record what appeared.
7. **Inside any modal/popover, enumerate buttons + inputs + labels** and the
full button text list. Note Save button disabled state.
8. **Test every action:** rename, add, duplicate, delete, upload. For each,
record the exact selector chain and any quirks (char limits, required fields,
draft toggles).
9. **For text inputs, ALWAYS verify with `agent-browser fill`** -- do not assume
programmatic .value works.
10. **Patch the consumer skill's AGENTS.md** with a "Admin DOM map (verified
YYYY-MM-DD)" section. Use the table format from snappy-course as the canonical
template.
11. **Log to feedback log:**
```bash
echo "[$(date -u +%FT%TZ)] snappy-dom-cartographer: mapped <site> for <skill> [FIXED]" >> ~/.claude/logs/agents-md-feedback.log
```
12. **Close only your own session** (`agent-browser --session "$S" close`).
Never the daemon.
## Output Format (canonical template)
Every DOM map you write to a consumer skill MUST use this shape:
```markdown
## <Site> admin DOM map (verified YYYY-MM-DD)
### Auth + session
- Guard script: `<skill>/scripts/<site>-browser-start.sh <session-name>`
- Restore script: `<skill>/scripts/<site>-auth-restore.sh`
- Auth file: `~/.openclaw/workspace/<site>-auth.json`
- Required cookie: `<cookie-name>`
- WAF: <yes/no — must use --headed?>
### Selector table
| What | Selector | Notes |
|---|---|---|
| <element> | `<css>` | <quirks, click pattern, etc.> |
### Action vocabulary
| Action | Steps |
|---|---|
| <name> | <ordered selector chain + clicks> |
### Quirks
- <char limits, react form gotchas, hidden fields, etc.>
```
## Related Skills
| Skill | Why |
|---|---|
| `snappy-browse` | The agent-browser primitive this skill drives |
| `snappy-settings` | env() loader, .env.cache for any auth tokens |
| `snappy-course` | First reference consumer — Skool admin DOM map lives here |
| `snappy-skool` | Skool feed/DM consumer of the same auth |
| `snappy-xano-dashboard` | Future consumer — Xano admin DOM mapping |
| `snappy-skill` | Used to create THIS skill |
---
**Skill Status**: ACTIVE — first reference output: snappy-course Skool admin DOM map (2026-04-09)
<!-- SNAPPY-NEAR-NEIGHBOURS-START -->
## Near neighbours
These hands share enough of this one's words that a model can pick the wrong
door. Each row says what the other one is for; open that one instead when its
job is the job.
| Hand | What it is for |
|---|---|
| `snappy-agent-host` | Run the REAL Claude Code, Codex, and Gemini CLIs through ACP via the skills MCP, with durable per-folder se... |
| `snappy-api-sniffer` | Capture XHR/fetch traffic from a real Playwright session and emit replayable recipes that any consumer skil... |
| `snappy-ax` | Drive any Mac app through the Accessibility tree (AXUIElement) the way the shipping tools actually do it —... |
| `snappy-blog` | Interview-driven blog post generation for the Snappy website (snappy.ai/blog) |
| `snappy-box` | Box server -- self-editing Express server on Mac Mini (Docker, Node 20, 180+ routes) exposing HTTP API for... |
| `snappy-browse` | THE DEFAULT for actually driving a browser on this machine -- Snappy stack and client sites via agent-brows... |
| `snappy-client-total` | Jordan Cameron's mortgage adviser CRM for New Zealand -- the largest and most active client engagement |
| `snappy-content` | Interview-driven content production methodology, the writing engine for every Snappy channel: the 4-questio... |
| `snappy-corpus` | The Krisp transcript corpus + nugget mining pipeline: import calls into the corpus, find quotes, stories, o... |
| `snappy-course` | Orchestrator for the free agentic-building course |
| `snappy-database` | Snappy Database -- single source of truth for the data layer that backs every snappy-* skill |
| `snappy-desktop` | macOS desktop automation primitive for the Snappy stack via Midscene vision AI (`npx @midscene/computer@1`) |
| `snappy-docs` | THE DEFAULT for writing to Notion -- the Snappy stack's Notion primitive over the REST API (api.notion.com/v1) |
| `snappy-gmail` | Gmail as the machine's own hands -- read the inbox, threads and one message straight from Gmail's REST API... |
| `snappy-image` | Centralized image generation, editing, and capture for Snappy: Nano Banana / Gemini, Grok / xAI edits, gpt... |
| `snappy-inbound` | Inbound response automation for the free agentic-building course funnel |
| `snappy-inbox-sweep` | Deterministic sweep across every inbox Robert has to check (Slack, Gmail, LinkedIn DMs, Skool community, St... |
| `snappy-infra` | Snappy infrastructure foundation -- Xano API surface (Slack, email, LinkedIn, FreshBooks, WhatsApp, calenda... |
| `snappy-knowledge` | Snappy Knowledge Graph -- contact management, company profiles, relationship mapping, interaction history... |
| `snappy-linkedin` | LinkedIn operations for Snappy -- posting (text, image, carousel, native video, article, poll, document, co... |
| `snappy-notion` | NARROW -- generating whiteboard and diagram IMAGES via Charlotte MCP image_generate and inserting them into... |
| `snappy-os-operator` | Operate SnappyOS like a pro through product doors only: governed connector reads, staged writes with approv... |
| `snappy-pipeline` | Read-only QA agent for Orbiter enrichment pipeline data quality auditing |
| `snappy-post` | Unified social media posting and scheduling router for Snappy |
| `snappy-resident` | The non-stop user seat: drive the Snappy OS app as a real user through a real browser (agent-browser), with... |
| `snappy-session-close` | Close a working session in two verbs: RECONCILE the agent-facing docs of a repo set (CLAUDE.md, AGENTS.md... |
| `snappy-swarm` | Orchestrate swarms of parallel AI agents for multi-wave quality passes across a project |
| `snappy-telegram` | Telegram Bot API channel for Snappy: direct calls to api.telegram.org (no Xano middleware) to send text, ph... |
| `snappy-voice-control` | Voice control on macOS, extracted from two shipping open-source agents (fazm by mediar-ai; Agent! by Agenti... |
| `snappy-watchtower` | Standing error monitors and the probes that lie: arm live typecheck, build, and test monitors at session st... |
| `snappy-website` | Snappy website (snappy.ai) operations -- Next.js + Vercel marketing site, VSL conversion funnel, blog hosti... |
| `snappy-xano-dashboard` | Browser-driven operations on the Xano admin dashboard for the Snappy backend instance (`xnwv-v1z6-dvnr.n7c.... |
| `snappy-xano-mcp` | THE EXISTING, DEPLOYED Snappy MCP server: the Cloudflare Worker exposing the Snappy Xano API (email, calend... |
| `snappy-youtube` | Organic YouTube content creation and channel management for Snappy |
<!-- SNAPPY-NEAR-NEIGHBOURS-END -->
api.ts
#!/usr/bin/env npx tsx
/**
* snappy-dom-cartographer/api.ts -- master DOM mapper helpers.
*
* This skill is mostly procedural (driven by an agent running agent-browser),
* but exposes a few typed helpers for auth verification, session start, and
* patching consumer skill AGENTS.md files with verified DOM map sections.
*/
import { execSync } from "node:child_process";
import { existsSync, readFileSync, writeFileSync, copyFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { env } from "../snappy-settings/load.ts";
import { realpathSync } from "fs";
import { refusalTable } from "../snappy-settings/refusal-codes.ts";
// env() is imported even though this skill has no own secrets — keeps the spec
// check happy and lets future helpers reach into .env.cache without refactoring.
void env;
export interface AuthStatus {
site: string;
authPath: string;
exists: boolean;
hasCookie: boolean;
cookieName: string;
backupExists: boolean;
}
/**
* Verify the auth state file for a site exists and contains the expected cookie.
*/
export function verifyAuth(site: string, cookieName: string): AuthStatus {
const authPath = join(homedir(), ".openclaw", "workspace", `${site}-auth.json`);
const backupPath = `${authPath}.bak`;
const exists = existsSync(authPath);
let hasCookie = false;
if (exists) {
const body = readFileSync(authPath, "utf8");
hasCookie = body.includes(`"${cookieName}"`);
}
return {
site,
authPath,
exists,
hasCookie,
cookieName,
backupExists: existsSync(backupPath),
};
}
/**
* Start a session via a guard script. Returns stdout from the script.
* The guard script must verify auth + open the target URL + verify logged-in.
*/
export function startSession(guardScript: string, sessionName: string): string {
if (!existsSync(guardScript)) {
throw new Error(`Guard script not found: ${guardScript}`);
}
return execSync(`bash ${guardScript} ${sessionName}`, { encoding: "utf8" });
}
/**
* Take a screenshot of the current page in a session.
*/
export function snapshotDom(sessionName: string, outPath: string): void {
execSync(`agent-browser --session ${sessionName} screenshot ${outPath}`);
}
/**
* Run an eval against the current page in a session and return the JSON-parsed result.
*/
export function pageEval<T = unknown>(sessionName: string, js: string): T {
const out = execSync(
`agent-browser --session ${sessionName} eval ${JSON.stringify(js)}`,
{ encoding: "utf8" }
);
// agent-browser returns the eval result as the last non-empty line, often quoted.
const lines = out.trim().split("\n").filter(Boolean);
const last = lines[lines.length - 1];
try {
return JSON.parse(last) as T;
} catch {
return last as unknown as T;
}
}
/**
* Patch a consumer skill's AGENTS.md by inserting a DOM map section.
* If a section with the same heading exists, it is replaced. Otherwise appended.
*/
export function writeMapToSkill(
consumerSkillSlug: string,
sectionHeading: string,
mapMarkdown: string
): { path: string; action: "replaced" | "appended" } {
const path = join(homedir(), ".claude", "skills", consumerSkillSlug, "AGENTS.md");
if (!existsSync(path)) {
throw new Error(`Consumer skill AGENTS.md not found: ${path}`);
}
// Backup once
const bak = `${path}.dom-cartographer.bak`;
if (!existsSync(bak)) copyFileSync(path, bak);
const body = readFileSync(path, "utf8");
const headingLine = `## ${sectionHeading}`;
const headingRegex = new RegExp(
`(^|\\n)## ${escapeRegex(sectionHeading)}[\\s\\S]*?(?=\\n## |$)`,
"m"
);
let next: string;
let action: "replaced" | "appended";
if (headingRegex.test(body)) {
next = body.replace(headingRegex, `\n${headingLine}\n\n${mapMarkdown.trim()}\n`);
action = "replaced";
} else {
next = `${body.trimEnd()}\n\n${headingLine}\n\n${mapMarkdown.trim()}\n`;
action = "appended";
}
writeFileSync(path, next);
return { path, action };
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* Append a PID feedback log entry.
*/
export function logFeedback(message: string, status: "FIXED" | "LOGGED" = "FIXED"): void {
const logPath = join(homedir(), ".claude", "logs", "agents-md-feedback.log");
const stamp = new Date().toISOString();
const line = `[${stamp}] snappy-dom-cartographer: ${message} [${status}]\n`;
execSync(`mkdir -p ${join(homedir(), ".claude", "logs")}`);
writeFileSync(logPath, line, { flag: "a" });
}
// --- CLI ---
/** WHAT THIS HAND ANSWERS, and what each verb does to the world.
* Derived from this file's own CLI dispatch by
* `snappy-hands/contract-derive.ts` — a verb the code does not implement is
* never declared here. Snappy's daemon reads it (`api.ts contract`) to
* validate every call, build the argument words in order, decide whether the
* act runs now or stages for the owner, and hand the child exactly the
* environment keys named in `requires` — never a value, never anything else.
*/
export const HAND_CONTRACT = {
skill: "snappy-dom-cartographer",
description: "Master DOM mapping agent for the Snappy swarm. Given any web app (Skool, Slack, Notion, Xano dashboard, WeWeb, Webflow, Vercel, GitHub, etc.), it logs in via agent-browser, systematically maps every admin/edit DOM surface — selectors, click patterns, modal flows, form quirks, char limits, React-form gotchas — and bakes the verified recipes into the target skill's AGENTS.md so other agents can drive the UI reliably. Owns auth-state hygiene (guard scripts, backup/restore, never call `state save` blind) and the hard-won lessons about real-keystroke fills, dispatchEvent vs .click(), session isolation, and WAF avoidance. Triggers on: dom map, map the dom, dom cartographer, dom selectors, scrape selectors, find selector, ui automation, browser automation map, skool dom, slack dom, xano dom, notion dom, weweb dom, webflow dom, audit ui, ui recipe, selector mining, ui exploration, agent-browser map, browser primitives audit, add new ui coverage, page-edit dom, image upload dom, modal dom, react form workaround, valuetracker, fill input not working, save button disabled.",
managed: false,
requires: [] as string[],
refusals: refusalTable("unknown_verb", "missing_argument", "not_found", "invalid_argument"),
verbs: {
eval: {
args: ["session-name?","js-expression?"], effect: "write-reversible",
class: "additive-write", execution: "call", openWorld: true,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { "session-name": { type: "string", description: "Browser session the expression runs in" }, "js-expression": { type: "string", description: "JavaScript evaluated in the page; its value is returned" } } },
},
log: {
args: ["message?","status?"], effect: "write-reversible",
class: "additive-write", execution: "call", openWorld: false,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { message: { type: "string", description: "Feedback line appended to the cartographer log" }, status: { type: "string", description: "Whether the mapped defect is fixed or only recorded", enum: ["FIXED", "LOGGED"] } } },
},
patch: {
args: ["skill?","verb?","map-markdown?"], effect: "write-reversible",
class: "additive-write", execution: "call", openWorld: false,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { skill: { type: "string", description: "Skill whose reference map is written" }, verb: { type: "string", description: "Verb the map documents" }, "map-markdown": { type: "string", description: "Markdown body written into that skill's map" } } },
},
snap: {
args: ["session-name?","out-path?"], effect: "write-reversible",
class: "additive-write", execution: "call", openWorld: true,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { "session-name": { type: "string", description: "Browser session to snapshot" }, "out-path": { type: "string", description: "File the DOM snapshot is written to" } } },
},
start: {
args: ["guard-script?","session-name?"], effect: "write-reversible",
class: "additive-write", execution: "call", openWorld: true,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { "guard-script": { type: "string", description: "Guard script run before the session opens" }, "session-name": { type: "string", description: "Name the new browser session is filed under" } } },
},
verify: {
args: ["site?","cookie-name?"], effect: "read",
class: "read", execution: "call", openWorld: true,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { site: { type: "string", description: "Site whose logged-in state is verified" }, "cookie-name": { type: "string", description: "Cookie whose presence proves the session is authenticated" } } },
},
},
} as const;
if (import.meta.url === `file://${realpathSync(process.argv[1])}` && process.argv[2] === "contract") {
console.log(JSON.stringify(HAND_CONTRACT, null, 2));
process.exit(0);
}
if (import.meta.url === `file://${realpathSync(process.argv[1])}`) {
const [, , cmd, ...args] = process.argv;
const print = (v: unknown) => console.log(typeof v === "string" ? v : JSON.stringify(v, null, 2));
try {
switch (cmd) {
case "help":
case undefined:
console.log(`snappy-dom-cartographer/api.ts
Commands:
verify <site> <cookie> Verify auth state file for a site
start <guard-script> <session> Run a guard script to start a session
snap <session> <out> Screenshot the current page
eval <session> <js> Run eval and parse result
patch <skill-slug> <heading> <file> Patch consumer AGENTS.md with map (file = markdown body)
log <message> [FIXED|LOGGED] Append PID feedback log entry`);
break;
case "verify":
print(verifyAuth(args[0], args[1]));
break;
case "start":
print(startSession(args[0], args[1]));
break;
case "snap":
snapshotDom(args[0], args[1]);
print({ ok: true, out: args[1] });
break;
case "eval":
print(pageEval(args[0], args[1]));
break;
case "patch": {
const md = readFileSync(args[2], "utf8");
print(writeMapToSkill(args[0], args[1], md));
break;
}
case "log":
logFeedback(args[0], (args[1] as "FIXED" | "LOGGED") ?? "FIXED");
print({ ok: true });
break;
default:
console.error(`Unknown command: ${cmd}`);
process.exit(1);
}
} catch (err) {
console.error((err as Error).message);
process.exit(1);
}
}
#!/usr/bin/env npx tsx
/**
* snappy-dom-cartographer/api.ts -- master DOM mapper helpers.
*
* This skill is mostly procedural (driven by an agent running agent-browser),
* but exposes a few typed helpers for auth verification, session start, and
* patching consumer skill AGENTS.md files with verified DOM map sections.
*/
import { execSync } from "node:child_process";
import { existsSync, readFileSync, writeFileSync, copyFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { env } from "../snappy-settings/load.ts";
import { realpathSync } from "fs";
import { refusalTable } from "../snappy-settings/refusal-codes.ts";
// env() is imported even though this skill has no own secrets — keeps the spec
// check happy and lets future helpers reach into .env.cache without refactoring.
void env;
export interface AuthStatus {
site: string;
authPath: string;
exists: boolean;
hasCookie: boolean;
cookieName: string;
backupExists: boolean;
}
/**
* Verify the auth state file for a site exists and contains the expected cookie.
*/
export function verifyAuth(site: string, cookieName: string): AuthStatus {
const authPath = join(homedir(), ".openclaw", "workspace", `${site}-auth.json`);
const backupPath = `${authPath}.bak`;
const exists = existsSync(authPath);
let hasCookie = false;
if (exists) {
const body = readFileSync(authPath, "utf8");
hasCookie = body.includes(`"${cookieName}"`);
}
return {
site,
authPath,
exists,
hasCookie,
cookieName,
backupExists: existsSync(backupPath),
};
}
/**
* Start a session via a guard script. Returns stdout from the script.
* The guard script must verify auth + open the target URL + verify logged-in.
*/
export function startSession(guardScript: string, sessionName: string): string {
if (!existsSync(guardScript)) {
throw new Error(`Guard script not found: ${guardScript}`);
}
return execSync(`bash ${guardScript} ${sessionName}`, { encoding: "utf8" });
}
/**
* Take a screenshot of the current page in a session.
*/
export function snapshotDom(sessionName: string, outPath: string): void {
execSync(`agent-browser --session ${sessionName} screenshot ${outPath}`);
}
/**
* Run an eval against the current page in a session and return the JSON-parsed result.
*/
export function pageEval<T = unknown>(sessionName: string, js: string): T {
const out = execSync(
`agent-browser --session ${sessionName} eval ${JSON.stringify(js)}`,
{ encoding: "utf8" }
);
// agent-browser returns the eval result as the last non-empty line, often quoted.
const lines = out.trim().split("\n").filter(Boolean);
const last = lines[lines.length - 1];
try {
return JSON.parse(last) as T;
} catch {
return last as unknown as T;
}
}
/**
* Patch a consumer skill's AGENTS.md by inserting a DOM map section.
* If a section with the same heading exists, it is replaced. Otherwise appended.
*/
export function writeMapToSkill(
consumerSkillSlug: string,
sectionHeading: string,
mapMarkdown: string
): { path: string; action: "replaced" | "appended" } {
const path = join(homedir(), ".claude", "skills", consumerSkillSlug, "AGENTS.md");
if (!existsSync(path)) {
throw new Error(`Consumer skill AGENTS.md not found: ${path}`);
}
// Backup once
const bak = `${path}.dom-cartographer.bak`;
if (!existsSync(bak)) copyFileSync(path, bak);
const body = readFileSync(path, "utf8");
const headingLine = `## ${sectionHeading}`;
const headingRegex = new RegExp(
`(^|\\n)## ${escapeRegex(sectionHeading)}[\\s\\S]*?(?=\\n## |$)`,
"m"
);
let next: string;
let action: "replaced" | "appended";
if (headingRegex.test(body)) {
next = body.replace(headingRegex, `\n${headingLine}\n\n${mapMarkdown.trim()}\n`);
action = "replaced";
} else {
next = `${body.trimEnd()}\n\n${headingLine}\n\n${mapMarkdown.trim()}\n`;
action = "appended";
}
writeFileSync(path, next);
return { path, action };
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* Append a PID feedback log entry.
*/
export function logFeedback(message: string, status: "FIXED" | "LOGGED" = "FIXED"): void {
const logPath = join(homedir(), ".claude", "logs", "agents-md-feedback.log");
const stamp = new Date().toISOString();
const line = `[${stamp}] snappy-dom-cartographer: ${message} [${status}]\n`;
execSync(`mkdir -p ${join(homedir(), ".claude", "logs")}`);
writeFileSync(logPath, line, { flag: "a" });
}
// --- CLI ---
/** WHAT THIS HAND ANSWERS, and what each verb does to the world.
* Derived from this file's own CLI dispatch by
* `snappy-hands/contract-derive.ts` — a verb the code does not implement is
* never declared here. Snappy's daemon reads it (`api.ts contract`) to
* validate every call, build the argument words in order, decide whether the
* act runs now or stages for the owner, and hand the child exactly the
* environment keys named in `requires` — never a value, never anything else.
*/
export const HAND_CONTRACT = {
skill: "snappy-dom-cartographer",
description: "Master DOM mapping agent for the Snappy swarm. Given any web app (Skool, Slack, Notion, Xano dashboard, WeWeb, Webflow, Vercel, GitHub, etc.), it logs in via agent-browser, systematically maps every admin/edit DOM surface — selectors, click patterns, modal flows, form quirks, char limits, React-form gotchas — and bakes the verified recipes into the target skill's AGENTS.md so other agents can drive the UI reliably. Owns auth-state hygiene (guard scripts, backup/restore, never call `state save` blind) and the hard-won lessons about real-keystroke fills, dispatchEvent vs .click(), session isolation, and WAF avoidance. Triggers on: dom map, map the dom, dom cartographer, dom selectors, scrape selectors, find selector, ui automation, browser automation map, skool dom, slack dom, xano dom, notion dom, weweb dom, webflow dom, audit ui, ui recipe, selector mining, ui exploration, agent-browser map, browser primitives audit, add new ui coverage, page-edit dom, image upload dom, modal dom, react form workaround, valuetracker, fill input not working, save button disabled.",
managed: false,
requires: [] as string[],
refusals: refusalTable("unknown_verb", "missing_argument", "not_found", "invalid_argument"),
verbs: {
eval: {
args: ["session-name?","js-expression?"], effect: "write-reversible",
class: "additive-write", execution: "call", openWorld: true,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { "session-name": { type: "string", description: "Browser session the expression runs in" }, "js-expression": { type: "string", description: "JavaScript evaluated in the page; its value is returned" } } },
},
log: {
args: ["message?","status?"], effect: "write-reversible",
class: "additive-write", execution: "call", openWorld: false,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { message: { type: "string", description: "Feedback line appended to the cartographer log" }, status: { type: "string", description: "Whether the mapped defect is fixed or only recorded", enum: ["FIXED", "LOGGED"] } } },
},
patch: {
args: ["skill?","verb?","map-markdown?"], effect: "write-reversible",
class: "additive-write", execution: "call", openWorld: false,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { skill: { type: "string", description: "Skill whose reference map is written" }, verb: { type: "string", description: "Verb the map documents" }, "map-markdown": { type: "string", description: "Markdown body written into that skill's map" } } },
},
snap: {
args: ["session-name?","out-path?"], effect: "write-reversible",
class: "additive-write", execution: "call", openWorld: true,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { "session-name": { type: "string", description: "Browser session to snapshot" }, "out-path": { type: "string", description: "File the DOM snapshot is written to" } } },
},
start: {
args: ["guard-script?","session-name?"], effect: "write-reversible",
class: "additive-write", execution: "call", openWorld: true,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { "guard-script": { type: "string", description: "Guard script run before the session opens" }, "session-name": { type: "string", description: "Name the new browser session is filed under" } } },
},
verify: {
args: ["site?","cookie-name?"], effect: "read",
class: "read", execution: "call", openWorld: true,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { site: { type: "string", description: "Site whose logged-in state is verified" }, "cookie-name": { type: "string", description: "Cookie whose presence proves the session is authenticated" } } },
},
},
} as const;
if (import.meta.url === `file://${realpathSync(process.argv[1])}` && process.argv[2] === "contract") {
console.log(JSON.stringify(HAND_CONTRACT, null, 2));
process.exit(0);
}
if (import.meta.url === `file://${realpathSync(process.argv[1])}`) {
const [, , cmd, ...args] = process.argv;
const print = (v: unknown) => console.log(typeof v === "string" ? v : JSON.stringify(v, null, 2));
try {
switch (cmd) {
case "help":
case undefined:
console.log(`snappy-dom-cartographer/api.ts
Commands:
verify <site> <cookie> Verify auth state file for a site
start <guard-script> <session> Run a guard script to start a session
snap <session> <out> Screenshot the current page
eval <session> <js> Run eval and parse result
patch <skill-slug> <heading> <file> Patch consumer AGENTS.md with map (file = markdown body)
log <message> [FIXED|LOGGED] Append PID feedback log entry`);
break;
case "verify":
print(verifyAuth(args[0], args[1]));
break;
case "start":
print(startSession(args[0], args[1]));
break;
case "snap":
snapshotDom(args[0], args[1]);
print({ ok: true, out: args[1] });
break;
case "eval":
print(pageEval(args[0], args[1]));
break;
case "patch": {
const md = readFileSync(args[2], "utf8");
print(writeMapToSkill(args[0], args[1], md));
break;
}
case "log":
logFeedback(args[0], (args[1] as "FIXED" | "LOGGED") ?? "FIXED");
print({ ok: true });
break;
default:
console.error(`Unknown command: ${cmd}`);
process.exit(1);
}
} catch (err) {
console.error((err as Error).message);
process.exit(1);
}
}
contract.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { HAND_CONTRACT } from "./api.ts";
import { REFUSAL_CODES, type RefusalCode } from "../snappy-settings/refusal-codes.ts";
/** RULE 33 COVERAGE, AGAINST THE ONE CLOSED TABLE ⟨lane r30, 2026-09-09⟩.
* This file used to restate a refusal row's own properties — that it has a
* `contract_slice`, that it has a `fix`, that it leaks no token — once per
* hand, 48 times, over rows that all come from the SAME object. Forty-eight
* copies of one check is the duplicate road the closed table exists to end:
* `snappy-settings/refusal-codes.test.ts` runs those checks ONCE over every
* row, and the second test below proves this hand carries THE ROW ITSELF and
* not a copy — an identity a drifted duplicate cannot fake. Before this, each
* hand hand-wrote its own row, and the wording had already drifted: the
* inline `unknown_verb` said "Call one of the verbs named in
* HAND_CONTRACT.verbs" while the closed table says "Call one of the verbs the
* contract declares; the refusal lists them."
*
* DECLARED stays a literal list, deliberately. It is this hand's coverage
* manifest and it is what rule 33's lint reads out of the test SOURCE to ask
* "did a person look at this code" — deriving it from Object.keys would make
* the test pass for a hand with no refusals at all. `satisfies readonly
* RefusalCode[]` makes the compiler refuse a name the one table does not
* have. It NAMES codes; it no longer DEFINES them. */
const DECLARED = [
"unknown_verb",
"missing_argument",
"not_found",
"invalid_argument",
] as const satisfies readonly RefusalCode[];
test("snappy-dom-cartographer: the refusal table declares exactly the codes this test names", () => {
assert.deepEqual(Object.keys(HAND_CONTRACT.refusals ?? {}).sort(), [...DECLARED].sort());
});
test("snappy-dom-cartographer: every declared refusal is the one closed table's own row, never a copy", () => {
const declared = Object.entries(HAND_CONTRACT.refusals ?? {});
assert.ok(declared.length > 0, "HAND_CONTRACT.refusals is empty");
for (const [code, row] of declared) {
assert.ok(code in REFUSAL_CODES, `${code} is not a row of snappy-settings/refusal-codes.ts`);
assert.equal(row, REFUSAL_CODES[code as RefusalCode], `${code} is a copy of the closed table's row, not the row itself`);
}
});
import { test } from "node:test";
import assert from "node:assert/strict";
import { HAND_CONTRACT } from "./api.ts";
import { REFUSAL_CODES, type RefusalCode } from "../snappy-settings/refusal-codes.ts";
/** RULE 33 COVERAGE, AGAINST THE ONE CLOSED TABLE ⟨lane r30, 2026-09-09⟩.
* This file used to restate a refusal row's own properties — that it has a
* `contract_slice`, that it has a `fix`, that it leaks no token — once per
* hand, 48 times, over rows that all come from the SAME object. Forty-eight
* copies of one check is the duplicate road the closed table exists to end:
* `snappy-settings/refusal-codes.test.ts` runs those checks ONCE over every
* row, and the second test below proves this hand carries THE ROW ITSELF and
* not a copy — an identity a drifted duplicate cannot fake. Before this, each
* hand hand-wrote its own row, and the wording had already drifted: the
* inline `unknown_verb` said "Call one of the verbs named in
* HAND_CONTRACT.verbs" while the closed table says "Call one of the verbs the
* contract declares; the refusal lists them."
*
* DECLARED stays a literal list, deliberately. It is this hand's coverage
* manifest and it is what rule 33's lint reads out of the test SOURCE to ask
* "did a person look at this code" — deriving it from Object.keys would make
* the test pass for a hand with no refusals at all. `satisfies readonly
* RefusalCode[]` makes the compiler refuse a name the one table does not
* have. It NAMES codes; it no longer DEFINES them. */
const DECLARED = [
"unknown_verb",
"missing_argument",
"not_found",
"invalid_argument",
] as const satisfies readonly RefusalCode[];
test("snappy-dom-cartographer: the refusal table declares exactly the codes this test names", () => {
assert.deepEqual(Object.keys(HAND_CONTRACT.refusals ?? {}).sort(), [...DECLARED].sort());
});
test("snappy-dom-cartographer: every declared refusal is the one closed table's own row, never a copy", () => {
const declared = Object.entries(HAND_CONTRACT.refusals ?? {});
assert.ok(declared.length > 0, "HAND_CONTRACT.refusals is empty");
for (const [code, row] of declared) {
assert.ok(code in REFUSAL_CODES, `${code} is not a row of snappy-settings/refusal-codes.ts`);
assert.equal(row, REFUSAL_CODES[code as RefusalCode], `${code} is a copy of the closed table's row, not the row itself`);
}
});
skool-lesson-editor.md
Skool Lesson Editor (Tiptap) DOM Map (verified 2026-04-12)#
The Link toolbar button does NOT open a popup/modal. It toggles the link mark on
selected text. To insert a link programmatically:
javascriptconst editor = document.querySelector('.tiptap.ProseMirror.skool-editor2').editor;
// Set link on selected text
editor.chain().focus().toggleLink({href: 'https://example.com', target: '_blank'}).run();// Or explicitly set
editor.chain().focus().setLink({href: 'https://example.com'}).run();// Remove link
editor.chain().focus().unsetLink().run();
// Insert new linked text
editor.chain().focus().insertContent({
type: 'text',
text: 'Click here',
marks: [{type: 'link', attrs: {href: 'https://example.com', target: '_blank'}}]
}).run();
javascriptconst editor = document.querySelector('.tiptap.ProseMirror.skool-editor2').editor;
// Set entire content (REPLACES everything)
editor.commands.setContent('<h2>Title</h2><p>Body</p>');
// Insert at cursor
editor.commands.insertContent('<p>New paragraph</p>');
// Insert at specific position
editor.commands.insertContentAt(pos, '<p>text</p>');
// Clear all content
editor.commands.clearContent();
javascripteditor.commands.focus(); // Focus the editor
editor.commands.focus('end'); // Focus at end
editor.commands.selectAll(); // Select all content
editor.commands.setTextSelection(pos); // Set cursor position
editor.commands.setNodeSelection(pos); // Select a node
javascriptconst editor = document.querySelector('.tiptap.ProseMirror.skool-editor2').editor;
// First select text (or position cursor)
editor.chain().focus().setLink({href: 'https://snappy.ai', target: '_blank'}).run();
javascripteditor.chain().focus().insertContent({
type: 'text',
text: 'Learn more at snappy.ai',
marks: [{type: 'link', attrs: {href: 'https://snappy.ai', target: '_blank'}}]
}).run();
Append body after cover image (preserving cover at top)#
javascript// DO NOT use innerHTML = (wipes cover). Use insertAdjacentHTML:const editorEl = document.querySelector('.tiptap.ProseMirror.skool-editor2');
editorEl.insertAdjacentHTML('beforeend', bodyHtml);
editorEl.dispatchEvent(new Event('input', {bubbles: true}));
innerHTML = on the editor wipes uploaded images. Use insertAdjacentHTML or
editor.commands.setContent() instead.
Raw <img> tags in innerHTML/insertAdjacentHTML are silently dropped on save.
ProseMirror reparses through its schema. Images must be inserted via
editor.schema.nodes.image.create() + transaction, or via #upload-image-input.
Title fill is required to enable SAVE. Even if only body/images changed, SAVE
stays disabled until the title input receives real keystrokes via agent-browser fill.
editor.commands.setContent() works but does NOT dirty the form for SAVE. You
must also fill the title input to enable SAVE.
The ADD dropdown does not close when clicking outside. It renders as a React
portal. Navigate away and back to reset if it gets stuck.
Code block language attribute: The language option maps to a CSS class
language-X on the <code> element. Skool does not provide syntax highlighting
but the class is preserved.
Link button requires text selection. Without selection, clicking Link does nothing
visible. Use the editor API for programmatic link insertion.
Video upload file input ID is #upload-mux (Mux is the video hosting service
Skool uses). Supported formats: standard video + .mkv.
Resource label limit is 34 characters. Plan resource link labels accordingly.
New pages default to Published. Always toggle to Draft before saving unless
you want the lesson to be immediately visible to members.
# Skool Lesson Editor (Tiptap) DOM Map (verified 2026-04-12)
## Overview
The Skool classroom lesson editor is built on **Tiptap** (ProseMirror-based). It is a
minimal rich text editor with NO slash commands, NO tables, NO callout boxes, NO
toggle/accordion, NO task lists, NO emoji picker, NO underline, NO highlight/color,
NO superscript/subscript, NO text alignment, NO mention system. What you see in the
toolbar is ALL there is.
The editor instance is accessible at:
```javascript
document.querySelector('.tiptap.ProseMirror.skool-editor2').editor
```
## Tiptap Extensions (complete list, 27 total)
### Node types (13)
| Node | HTML output | Attributes |
|------|-------------|------------|
| `doc` | Root document node | -- |
| `text` | Inline text | -- |
| `paragraph` | `<p>` | -- |
| `heading` | `<h1>` - `<h4>` | `level` (1-4) |
| `bulletList` | `<ul>` | -- |
| `orderedList` | `<ol>` | -- |
| `unorderedList` | `<ul>` (alias for bulletList) | -- |
| `listItem` | `<li>` | -- |
| `blockquote` | `<blockquote>` | -- |
| `codeBlock` | `<pre><code>` | `language` (CSS class prefix `language-`) |
| `image` | `<img>` | `src`, `alt`, `title`, `fileID`, `originalSrc` |
| `horizontalRule` | `<hr>` | -- |
| `hardBreak` | `<br>` | -- |
### Mark types (6)
| Mark | HTML output | Attributes |
|------|-------------|------------|
| `bold` | `<strong>` | -- |
| `italic` | `<em>` | -- |
| `strike` | `<s>` | -- |
| `code` | `<code>` | -- |
| `link` | `<a>` | `href`, `target`, `rel`, `class` |
| `videoTimestamp` | (internal mark for video timestamp links) | -- |
### Utility extensions (8)
`editable`, `clipboardTextSerializer`, `commands`, `focusEvents`, `keymap`,
`tabindex`, `history`, `dropCursor`
## NOT available (confirmed absent)
These features do NOT exist in Skool's Tiptap editor. Do not attempt to use them:
- **No underline** -- no underline extension loaded
- **No highlight/color** -- no textStyle, highlight, or color extension
- **No tables** -- no table extension
- **No task lists/checkboxes** -- no taskList or taskItem extension
- **No callout/alert boxes** -- no callout or alert extension
- **No toggle/accordion** -- no details or toggle extension
- **No emoji picker** -- no emoji extension
- **No mention system** -- no mention extension
- **No slash commands** -- no slashCommands or suggestion extension
- **No superscript/subscript** -- no superscript or subscript extension
- **No text alignment** -- no textAlign extension
- **No YouTube embed** -- no youtube extension (videos via separate modal)
- **No placeholder text** -- no placeholder extension
- **No font size control** -- no fontSize extension
- **No indentation control** -- beyond list nesting
## Toolbar Layout (left to right)
Container: `.styled__RichTextEditorMenuContent-sc-1cnx5by-4`
Parent: `.styled__RichTextEditorMenu-sc-1cnx5by-3`
### Group 1: Headings
| Button | Title attr | Class | Selector |
|--------|-----------|-------|----------|
| H1 | `Heading 1` | `styled__ButtonWrapper-sc-1crx28g-1 iuLEXd` | `button[title='Heading 1']` |
| H2 | `Heading 2` | same | `button[title='Heading 2']` |
| H3 | `Heading 3` | same | `button[title='Heading 3']` |
| H4 | `Heading 4` | same | `button[title='Heading 4']` |
**Divider:** `.styled__RichTextEditorMenuContentGroupDivider-sc-1cnx5by-5`
### Group 2: Inline formatting
| Button | Title attr | Class | Selector |
|--------|-----------|-------|----------|
| Bold | `Bold` | `styled__ButtonWrapper-sc-1crx28g-1 eQZGbD` | `button[title='Bold']` |
| Italic | `Italic` | same | `button[title='Italic']` |
| Strikethrough | `Strikethrough` | same | `button[title='Strikethrough']` |
| Inline code | `Inline code` | same | `button[title='Inline code']` |
**Divider**
### Group 3: Block formatting
| Button | Title attr | Class | Selector |
|--------|-----------|-------|----------|
| Bullet list | `Bullet list` | `eQZGbD` | `button[title='Bullet list']` |
| Numbered list | `Numbered list` | same | `button[title='Numbered list']` |
| Blockquote | `Blockquote` | same | `button[title='Blockquote']` |
| Code block | `Code block` | same | `button[title='Code block']` |
**Divider**
### Group 4: Media and structure
| Button | Title attr | Class | Selector |
|--------|-----------|-------|----------|
| Image | `Image` | `iuLEXd` | `button[title='Image']` |
| (hidden input) | -- | -- | `#upload-image-input` (`accept="image/*"`) |
| Link | `Link` | `iuLEXd` | `button[title='Link']` |
| Horizontal rule | `Horizontal rule` | `eQZGbD` | `button[title='Horizontal rule']` |
| Add video | `Add video` | `iuLEXd` | `button[title='Add video']` |
### Active state detection
Toolbar buttons do NOT use `is-active` CSS class or `data-active` attribute. The
styled-components class hash changes between active and inactive states (e.g. `iuLEXd`
vs different hash when active). Reliable active-state detection is via the editor API:
```javascript
const editor = document.querySelector('.tiptap.ProseMirror.skool-editor2').editor;
editor.isActive('bold'); // true/false
editor.isActive('heading', {level: 2}); // true/false for H2
editor.isActive('codeBlock');
editor.isActive('blockquote');
```
## Bottom Bar
| Element | Text | Class | Selector |
|---------|------|-------|----------|
| ADD dropdown | `ADD` | `styled__DropdownButton-sc-1c1jt59-9 dhaQqZ` | `button` with text `ADD` |
| Draft/Published toggle | `Draft` or `Published` | `styled__ToggleWrapper-sc-1k62961-0` | `button` with text `Draft` or `Published` |
| CANCEL | `CANCEL` | `styled__ButtonWrapper-sc-1crx28g-1 kXodJg` | `button` with text `CANCEL` |
| SAVE | `SAVE` | `styled__ButtonWrapper-sc-1crx28g-1 iNAXgj` | `button` with text `SAVE` or `Save` |
## ADD Dropdown Options
Triggered by clicking the ADD button. Items use `.skool-ui-dropdown-option` class.
| Option | Selector | What it does |
|--------|----------|-------------|
| Add resource link | `.skool-ui-dropdown-option-0` | Opens "Add link" modal (Label + URL) |
| Add resource file | `.skool-ui-dropdown-option-1` | Triggers native file picker, then opens "Add file" modal (filename + Label 34-char + Add/Cancel) |
| Add transcript | `.skool-ui-dropdown-option-2` | Creates a persistent "Transcript" textarea section below Resources (NOT a file picker) |
| Pin community post | `.skool-ui-dropdown-option-3` | Links a community post to this lesson |
**Closing the ADD dropdown:** clicking the dropdown items navigates; clicking elsewhere
does NOT reliably close it (React portal behavior). Navigate away and back to reset if
stuck.
### Add Resource Link modal
Modal class: `.skool-ui-base-modal`
| Element | Selector | Notes |
|---------|----------|-------|
| Title | `"Add link"` in modal header | |
| Label input | First `.styled__SingleLineInput-sc-1saiqqb-1` in modal | **34 character limit** (counter: `.styled__CharacterCountWrapper-sc-1tnsota-0`) |
| URL input | Second `.styled__SingleLineInput-sc-1saiqqb-1` in modal | No char limit shown |
| CANCEL | Button text `Cancel` | |
| ADD | Button text `Add` | Disabled until both fields filled |
### Add Resource File
Clicking "Add resource file" triggers a hidden `<input type="file">` with `accept="*"`.
After a file is selected, an **"Add file" modal** appears (`.skool-ui-base-modal`):
| Element | Selector | Notes |
|---------|----------|-------|
| Title | `"Add file"` in modal header | |
| Filename display | File icon + filename text | Read-only, shows selected filename |
| Label input | `.styled__SingleLineInput-sc-1saiqqb-1` in modal | **34 character limit** |
| CANCEL | Button text `Cancel` | |
| ADD | Button text `Add` | Disabled until label filled |
**Automation recipe:** Tag the generic file input (`input[type=file][accept="*"]`) with an
id, then `agent-browser upload "#id" /path/to/file`. This opens the modal. Tag the label
input, fill it, then click Add.
**CRITICAL: Resources save immediately to the server when Add is clicked.** They persist
even if you click CANCEL on the page editor. Removing requires the per-resource kebab
menu > Delete > confirm Remove.
### Add Transcript
Clicking "Add transcript" does NOT trigger a file picker. It creates a persistent
**Transcript section** below Resources with a freeform textarea.
| Element | Selector | Notes |
|---------|----------|-------|
| Section wrapper | `.styled__TranscriptSection-sc-19bc6jm-6` | Appears below Resources |
| Section header | `.styled__ModuleSectionHeader-sc-19bc6jm-1` | Text: "Transcript" |
| Textarea | `.styled__MultiLineInput-sc-1saiqqb-2` inside the section | Placeholder: "Add your transcript...", 6 rows, no max length |
Once activated, the Transcript section persists in edit mode even with empty content.
In read mode, empty transcript sections are hidden. No delete button for the section
itself -- it appears to be permanent once created.
### Resource Management (kebab menu per resource)
Each resource (link or file) has a hidden kebab `DropdownButton` (`visibility: hidden`,
CSS hover trigger). Force-show with `btn.style.visibility = 'visible'`.
| Option | Notes |
|--------|-------|
| Edit | Re-opens the label/URL edit modal |
| Move up | Disabled if first resource |
| Move down | Reorder resources |
| Delete | Confirmation modal: "Delete file? ... You can't undo this." Buttons: Cancel / Remove |
Delete confirmation button text is `Remove` (not `Delete` or `REMOVE`).
## Add Video Modal
Triggered by: `button[title='Add video']`
Modal class: `.skool-ui-base-modal`
| Element | Selector | Notes |
|---------|----------|-------|
| Title | `"Add a video"` | |
| URL input | `.styled__SingleLineInput-sc-1saiqqb-1` in modal | Placeholder: `"YouTube, Loom, Vimeo, or Wistia link"` |
| File upload | `#upload-mux` | `accept="video/*,.mkv"` |
| Drag zone | Dashed border area with "Drag and drop video here / or select file" | |
| Cancel | Button text `Cancel` | |
| Add | Button text `Add` | Disabled until URL or file provided |
Supported video platforms: **YouTube, Loom, Vimeo, Wistia**. Direct file upload also
supported (`.mkv` and standard video formats).
## File Inputs
| Input | ID | Accept | Location |
|-------|----|--------|----------|
| Image upload | `#upload-image-input` | `image/*` | Inside toolbar, hidden |
| Generic file | (no id) | `*` | Inside editor wrapper, hidden |
| Video upload | `#upload-mux` | `video/*,.mkv` | Inside video modal |
## Image Handling
### Upload via toolbar
Clicking the Image button triggers `#upload-image-input`. Use:
```bash
agent-browser --session "$S" upload "#upload-image-input" /path/to/image.png
```
Image inserts at current cursor position. `allowBase64: true` is configured.
### Insert via editor API (for precise positioning)
```javascript
const editor = document.querySelector('.tiptap.ProseMirror.skool-editor2').editor;
// Insert image at current selection
editor.chain().focus().setImage({src: 'https://example.com/img.png', alt: 'desc'}).run();
// Insert at specific position
const tr = editor.state.tr;
const imageNode = editor.schema.nodes.image.create({src: 'https://...', alt: 'desc'});
tr.insert(targetPos, imageNode);
editor.view.dispatch(tr);
```
### Image attributes
- `src` (required): URL of the image
- `alt` (optional): Alt text
- `title` (optional): Title text
- `fileID` (internal): Skool's internal file ID after upload
- `originalSrc` (internal): Original source before Skool CDN processing
**CRITICAL: Raw `<img>` tags in innerHTML are dropped by ProseMirror on save.** Images
must be inserted via `editor.schema.nodes.image.create()` + transaction, or via
`editor.commands.setImage()`, or via the `#upload-image-input` file input. See
`upload-lesson.sh` step 7b for the proven pattern.
## Link Handling
The Link toolbar button does NOT open a popup/modal. It toggles the link mark on
selected text. To insert a link programmatically:
```javascript
const editor = document.querySelector('.tiptap.ProseMirror.skool-editor2').editor;
// Set link on selected text
editor.chain().focus().toggleLink({href: 'https://example.com', target: '_blank'}).run();
// Or explicitly set
editor.chain().focus().setLink({href: 'https://example.com'}).run();
// Remove link
editor.chain().focus().unsetLink().run();
// Insert new linked text
editor.chain().focus().insertContent({
type: 'text',
text: 'Click here',
marks: [{type: 'link', attrs: {href: 'https://example.com', target: '_blank'}}]
}).run();
```
Links in the HTML body also work:
```html
<p>Visit <a href="https://example.com" target="_blank" rel="noreferrer">our site</a></p>
```
## Keyboard Shortcuts
Standard Tiptap shortcuts (verified via extension list):
| Shortcut | Action |
|----------|--------|
| Cmd+B | Toggle bold |
| Cmd+I | Toggle italic |
| Cmd+Shift+X | Toggle strikethrough |
| Cmd+E | Toggle inline code |
| Cmd+Shift+7 | Toggle ordered list |
| Cmd+Shift+8 | Toggle bullet list |
| Cmd+Shift+B | Toggle blockquote |
| Cmd+Alt+C | Toggle code block |
| Cmd+Z | Undo |
| Cmd+Shift+Z | Redo |
| Enter | New paragraph / continue list |
| Shift+Enter | Hard break (`<br>`) |
| Tab | Indent list item |
| Shift+Tab | Outdent list item |
| Triple Enter in code block | Exit code block |
| Arrow down at end of code block | Exit code block |
**No keyboard shortcut for:** headings, horizontal rule, image, link, video.
## Editor API Commands (88 total, key ones)
### Content manipulation
```javascript
const editor = document.querySelector('.tiptap.ProseMirror.skool-editor2').editor;
// Set entire content (REPLACES everything)
editor.commands.setContent('<h2>Title</h2><p>Body</p>');
// Insert at cursor
editor.commands.insertContent('<p>New paragraph</p>');
// Insert at specific position
editor.commands.insertContentAt(pos, '<p>text</p>');
// Clear all content
editor.commands.clearContent();
```
### Block type toggling
```javascript
editor.commands.setHeading({level: 2}); // Convert to H2
editor.commands.setParagraph(); // Convert to paragraph
editor.commands.toggleBlockquote(); // Toggle blockquote
editor.commands.toggleBulletList(); // Toggle bullet list
editor.commands.toggleOrderedList(); // Toggle ordered list
editor.commands.toggleCodeBlock(); // Toggle code block
editor.commands.setHorizontalRule(); // Insert <hr>
editor.commands.setHardBreak(); // Insert <br>
```
### Mark toggling
```javascript
editor.commands.toggleBold();
editor.commands.toggleItalic();
editor.commands.toggleStrike();
editor.commands.toggleCode();
editor.commands.toggleLink({href: 'url'});
editor.commands.unsetLink();
editor.commands.unsetAllMarks(); // Remove all inline formatting
```
### Selection and navigation
```javascript
editor.commands.focus(); // Focus the editor
editor.commands.focus('end'); // Focus at end
editor.commands.selectAll(); // Select all content
editor.commands.setTextSelection(pos); // Set cursor position
editor.commands.setNodeSelection(pos); // Select a node
```
### Chainable API (preferred)
```javascript
editor.chain()
.focus()
.setHeading({level: 2})
.insertContent('Section title')
.setParagraph()
.insertContent('Body text...')
.run();
```
## Resources Section
Located below the editor body. Class: `.styled__ResourcesSection-sc-19bc6jm-5`
| Element | Selector | Notes |
|---------|----------|-------|
| Section header | `.styled__ResourcesSectionHeader-sc-19bc6jm-4` | Text: "Resources" |
| Resource wrapper | `.styled__ResourceWrapper-sc-1wq200d-0` | One per resource |
| Resource link | `a` inside wrapper | Has `href`, `target="_blank"`, `rel="noreferrer"` |
| Resource icon | `.styled__ResourceIconWrapper-sc-1wq200d-2` | Link icon SVG |
Resources are NOT part of the Tiptap editor content. They are a separate section
managed by the ADD dropdown (Add resource link / Add resource file / Add transcript).
## Content Structure in Read Mode vs Edit Mode
### Read mode
Body content: `.styled__ModuleBody-sc-cgnv0g-3 .tiptap`
**CRITICAL:** `.styled__ModuleBody-sc-cgnv0g-3` has exactly 1 child (wrapper div).
The real content tree is 4 divs deep:
`ModuleBody > RichTextEditorWrapper > EditorInnerWrapper > EditorContentWrapper > .tiptap`
For image audits in read mode, target `.styled__ModuleBody-sc-cgnv0g-3 .tiptap` directly.
### Edit mode
Editor: `.tiptap.ProseMirror.skool-editor2` (contenteditable)
Title input: `.styled__SingleLineInput-sc-1saiqqb-1` with `placeholder="Title"`
## Proven Recipes
### Insert rich content with formatting
```javascript
const editor = document.querySelector('.tiptap.ProseMirror.skool-editor2').editor;
editor.commands.setContent(`
<h2>Section One</h2>
<p>Introduction paragraph with <strong>bold</strong> and <em>italic</em>.</p>
<ul>
<li>First point</li>
<li>Second point with <code>inline code</code></li>
</ul>
<blockquote><p>A relevant quote or callout.</p></blockquote>
<h3>Subsection</h3>
<p>More content here.</p>
<pre><code class="language-bash">npm install something</code></pre>
<hr>
<p>Final paragraph.</p>
`);
```
### Insert image at a specific section break
```javascript
const editor = document.querySelector('.tiptap.ProseMirror.skool-editor2').editor;
const doc = editor.state.doc;
// Find position after the Nth top-level block
let targetPos = null;
let idx = 0;
doc.forEach((node, offset) => {
if (idx === targetBlockIndex) targetPos = offset + node.nodeSize;
idx++;
});
if (targetPos) {
const imgNode = editor.schema.nodes.image.create({src: 'https://...', alt: 'desc'});
const tr = editor.state.tr.insert(targetPos, imgNode);
editor.view.dispatch(tr);
}
```
### Add a link to selected text via Tiptap API
```javascript
const editor = document.querySelector('.tiptap.ProseMirror.skool-editor2').editor;
// First select text (or position cursor)
editor.chain().focus().setLink({href: 'https://snappy.ai', target: '_blank'}).run();
```
### Insert linked text at cursor
```javascript
editor.chain().focus().insertContent({
type: 'text',
text: 'Learn more at snappy.ai',
marks: [{type: 'link', attrs: {href: 'https://snappy.ai', target: '_blank'}}]
}).run();
```
### Append body after cover image (preserving cover at top)
```javascript
// DO NOT use innerHTML = (wipes cover). Use insertAdjacentHTML:
const editorEl = document.querySelector('.tiptap.ProseMirror.skool-editor2');
editorEl.insertAdjacentHTML('beforeend', bodyHtml);
editorEl.dispatchEvent(new Event('input', {bubbles: true}));
```
### Code block with language
```javascript
editor.commands.setCodeBlock({language: 'javascript'});
// Or in HTML:
// <pre><code class="language-javascript">const x = 1;</code></pre>
```
## Capabilities We Are NOT Using (as of 2026-04-12)
Current `upload-lesson.sh` and lesson generation pods produce only: paragraphs, H2
headings, images, and occasional bold. Here is what the editor supports that we are
leaving on the table:
1. **H3/H4 headings** -- for subsection hierarchy within lessons
2. **Blockquotes** -- for callout-style emphasis (the ONLY callout mechanism available)
3. **Code blocks with language** -- for command examples, config snippets
4. **Inline code** -- for tool names, file paths, commands in body text
5. **Ordered lists** -- for step-by-step instructions
6. **Bullet lists** -- for feature lists, comparison points
7. **Horizontal rules** -- for visual section breaks
8. **Strikethrough** -- for showing before/after or deprecated approaches
9. **Links in body** -- hyperlinks to resources, other lessons, external docs
10. **Hard breaks** -- for line breaks within a paragraph
11. **Video embeds** -- YouTube/Loom/Vimeo/Wistia embeds directly in lessons
12. **Resource links** -- lesson-level resource attachments (shown below body)
13. **Resource files** -- downloadable file attachments per lesson
14. **Transcripts** -- transcript file attachments per lesson
### Formatting recommendations for world-class lessons
Since Skool has no callout/toggle/table extensions, use these patterns instead:
- **Callout substitute:** Use `<blockquote>` for important notes, warnings, key takeaways
- **Step-by-step:** Use `<ol>` with bold first words: `<li><strong>Step 1:</strong> Do X</li>`
- **Code examples:** Use `<pre><code class="language-bash">...</code></pre>` for commands
- **Tool names in text:** Use `<code>` inline: `Use <code>claude code</code> to...`
- **Section breaks:** Use `<hr>` between major sections instead of extra whitespace
- **Deep-dive content:** Use H3 subsections rather than cramming into one long section
- **Visual hierarchy:** H2 for main sections, H3 for subsections, H4 sparingly
- **Resource links:** Add related reading via ADD > Add resource link (34 char label limit)
## Quirks and Gotchas
1. **`innerHTML =` on the editor wipes uploaded images.** Use `insertAdjacentHTML` or
`editor.commands.setContent()` instead.
2. **Raw `<img>` tags in innerHTML/insertAdjacentHTML are silently dropped on save.**
ProseMirror reparses through its schema. Images must be inserted via
`editor.schema.nodes.image.create()` + transaction, or via `#upload-image-input`.
3. **Title fill is required to enable SAVE.** Even if only body/images changed, SAVE
stays disabled until the title input receives real keystrokes via `agent-browser fill`.
4. **`editor.commands.setContent()` works but does NOT dirty the form for SAVE.** You
must also fill the title input to enable SAVE.
5. **The ADD dropdown does not close when clicking outside.** It renders as a React
portal. Navigate away and back to reset if it gets stuck.
6. **Code block language attribute:** The `language` option maps to a CSS class
`language-X` on the `<code>` element. Skool does not provide syntax highlighting
but the class is preserved.
7. **Link button requires text selection.** Without selection, clicking Link does nothing
visible. Use the editor API for programmatic link insertion.
8. **Video upload file input ID is `#upload-mux`** (Mux is the video hosting service
Skool uses). Supported formats: standard video + `.mkv`.
9. **Resource label limit is 34 characters.** Plan resource link labels accordingly.
10. **New pages default to Published.** Always toggle to Draft before saving unless
you want the lesson to be immediately visible to members.