← All Skills

snappy-artifact-loop

v1.0.0
5 files, 83.3 KB ~8,451 words · 34 min read Updated 2026-09-09

snappy-artifact-loop skill

38 of 48 checks pass
What it can do
channelsread
choose opts-json?read
envelope kind payload-json page-key?read
kit kind page-keyread
parse text?read
What does not pass yet
$ npx snappy-skills install snappy-artifact-loop
zip ↓
Documents
AGENTS.md

snappy-artifact-loop — loader#

A published Artifact is an input device, and you are its backend. Do not

build a static output document when the page could collect judgment you act on.

Pick the channel first — it is the design decision#

Channel Page → you You → page Verified Use when
clipboard human pastes ◆LOOP block DEFAULT. Review/rating/approval. No capability, no consent, no billing.
comments Artifact action:"comments" action:"reply" threaded prose; reply lands only on human-activated threads
selfpublish page writes state into own HTML → you WebFetch the URL redeploy same path VERIFIED 2026-09-03 close the loop with no human carrying the payload
sample page asks Claude inline untested judgment needed during the interaction; viewer pays
db page-side realtime write_db/read_db absent 2026-09-03 multi-session state — verify agent-side tools BEFORE promising a loop
room ephemeral; you can join as kind "agent" emit on admin topic untested live multi-viewer presence

Never declare a capability you do not use — each is a consent prompt, and mcp

bars public sharing.

Envelope contract (channel A)#

◆LOOP v1 kind=<slug> page=<key>
{"items":[{"id":"…","verdict":"…","note":"…","meta":{…}}]}
◆END
  • One envelope per page, one copy button, at the end.
  • kind names the action to take — you branch on it.
  • Every item carries the addressable id/URL, not just a verdict.
  • Include what you cannot re-derive (refs used, round, prompt hash).

Commands#

bashS=~/.claude/skills/snappy-artifact-loop/api.ts
npx tsx $S channels --json                 # decision table
npx tsx $S choose '{"prose":true}' --json  # recommend a channel
npx tsx $S kit draft-review q3-launch     # embeddable copy-back block
npx tsx $S envelope gallery-rating '{"items":[…]}'
pbpaste | npx tsx $S parse --json          # read a pasted envelope

Page markup the kit expects:

html<div data-loop-item="r4-selfie" data-loop-meta='{"round":4}'>
  <button data-loop-verdict="fire">🔥</button>
  <textarea data-loop-note></textarea>
</div>

Artifact hard constraints#

  • Strict CSP: no CDN, external fonts, remote images, or fetch. Inline all;

images as data: URIs.

  • 16MB ceiling, base64 inflates ~33% → sips -Z 620 -s formatOptions 55.
  • No <!doctype>/<html>/<head>/<body> — the platform wraps it.
  • Theme-aware: prefers-color-scheme plus :root[data-theme=…] overrides.
  • Same file path = same URL on redeploy. Stable favicon, always.
  • navigator.clipboard works here (real https origin), unlike file:// pages —

keep the execCommand fallback anyway.

Judgment-surface defaults#

  1. Comparison beats absolute judgment — lay options side by side.
  2. Blind when you have a stake — randomise sides, blur arm labels until the vote.
  3. Provenance behind <details> — visible is noise, one click is trust.
  4. Capture judgment as data, not prose that scrolls away.
  5. State the honest read next to each item; do not hide known defects.

Application grade — what makes it feel like an app#

Ranked by feel-per-line. A review widget is not an application.

  1. Keyboard-first + auto-advance — Lightroom's review loop: P keep / X

discard / U clear, cursor advances itself. Highest-throughput judgment loop

known; applies to drafts, model outputs, candidates, search results alike.

  1. ⌘K command palette — every action typeable; doubles as the feature list.
  2. Roving tabindex — one cell tabindex=0, rest -1, arrows move focus AND the 0.
  3. Undo/redo command pattern{do,undo} objects on a stack. Makes rating fearless.
  4. Faceted rail with live counts — the counts are the affordance.
  5. Bulk select + floating action bar — appears on selection, near the selection.
  6. URL as state — filters + open item in the hash; every view linkable.
  7. Optimistic UI — paint before persisting, always.
  8. Progressive disclosure — detail behind <details> (NN/g: −55% cognitive load).
  9. View Transitionsdocument.startViewTransition, 3 lines, kills the document feel.
  10. IntersectionObserver lazy load, rootMargin:400px — data: URIs still cost decode.
  11. CSS Grid Lanes masonry behind @supports, plain-grid fallback.

Richer clipboard#

Text fence is the floor. Async Clipboard web custom formats ride alongside:

'web application/x-artifact-loop+json' (Chromium 104+, ≤100 formats).

Always write text/plain too — it is what survives a paste into chat today.

Landscape (do not reinvent)#

  • MCP Apps (2026-01-26, @modelcontextprotocol/ext-apps) — interactive UI

inside the chat, bidirectional. Use when the surface belongs in the conversation.

  • AG-UI — ~16 agent↔frontend event types incl. STATE_DELTA (JSON Patch). Steal the vocabulary.
  • Agent Inbox (LangChain) — HITL as a priority inbox; check-ins are

Notify / Question / Review; approvals are approve / reject-with-msg / edit-args / respond.

  • Simon Willison's 150+ HTML tools — closest prior art, but his env allows CDN +

CORS fetch and artifacts allow NEITHER. Do not copy his dependency patterns.

  • Users strongly prefer generated interactive UI over markdown (GenUI research).

Reference implementation: gallery_app.html — 61 Xano images, every pattern above, ~400 lines vanilla.

⭐⭐ CHANNEL H — the page calls the AGENT (strongest; no capability needed)#

sample() gives a page a model. bridge.py gives it you — MCP servers,

filesystem, image pipeline, skills, this session's context. Different category:

sample reshapes what the page has; the bridge changes the world.

VERIFIED 2026-09-03 end-to-end: a page asked for a new image to be generated →

the real pipeline fired → URL returned in 24s. No model call could have done that.

page ─POST /ask─▶ bridge 127.0.0.1:8787 ─▶ queue/req-<id>.json
                                                 │ your Monitor fires → chat
page ◀─long-poll─ bridge ◀─POST /answer─ you (any tool)

bridge.py ships in this skill. GET / serves the app (same-origin),

/pending shows what's waiting, /answer releases the poll.

Rules: bind 127.0.0.1 only (unauthenticated hole into an agent) · latency =

your turn cadence, long-poll ~240s · serve the app FROM the bridge · probe with

~1.5s AbortController so no-bridge falls through fast · **launch DETACHED

(nohup … &, never a bare &)** — a bare background job dies when the harness

kills its parent shell, and the page then long-polls 240s with nobody home,

which reads as "the app broke" · the bridge dies with the session, so say so in

any handoff · show which engine resolved, so a dead backend never looks live.

Monitor loop must use find, NOT a glob — zsh kills an unmatched glob

(no matches found, exit 1, monitor dead):

bashwhile true; do find "$Q" -name 'req-*.json' -type f 2>/dev/null | while read -r f; do
  [ -e "$f.seen" ] || { touch "$f.seen"; printf 'ASK '; cat "$f"; echo; }; done; sleep 1; done

THE THREE-ENGINE SHIM — write this, never bind to one engine#

  1. claude.use('sample') (artifact host) → 2. local bridge (you, with tools)

→ 3. BYOK key. Degrade quietly, show which engine resolved.

⚠️ ASSERT EVERY STRING EDIT#

A replace() that matches nothing changes nothing and reports success. Cost an

hour: LLMdirectly vs LLM directly, one space, silent no-op, page shipped

without its bridge probe while the build printed "wired".

pythonassert old in s, f"NOT FOUND → {label}"
assert s.count(old)==1, f"AMBIGUOUS {s.count(old)}× → {label}"

Then verify the shipped artifact, not the source you think you edited:

grep -c the built file AND curl | grep -c what is actually served, and

node --check the extracted script. Same shape as: the "dead" phrase live in 6

sites, and db reported broken from a guessed return type. **An unverified

write that reports success is worse than a failure — a failure tells you.**

⚠️ WHERE THE POWER COMES FROM — read this first#

window.claude is host-injected middleware, not a web feature. The published

artifact's raw HTML carries a __FRAME_PREAMBLE naming the capability bundles the

platform loads. A local file has no window.claude because nobody injected one.

Artifact and local file are near-exact COMPLEMENTS:

artifact your own HTML
sample/db/room/mcp/downloads/sendToClaudeSession ✅ injected ❌ absent
cross-origin fetch, remote images, your API keys ❌ CSP ✅ unrestricted

So always write against a shim, never claude.use() directly — one file then

runs in both worlds:

jsasync function getSampler(){
  const s = window.claude?.use ? await window.claude.use('sample') : null;
  if (s) return {via:'host', fn: async q => (await s(q)).text};
  const key = localStorage.getItem('llm:key');           // local: your own middleware
  if (!key) return null;                                  // degrade, don't break
  return {via:'byok', fn: async q => { /* fetch OpenRouter / Anthropic */ }};
}

Anthropic direct from a browser needs anthropic-dangerous-direct-browser-access: true;

OpenRouter needs nothing special.

Simon's HTML-tool patterns in an artifact#

9 of his 14 work unchanged. Only network-touching ones die (strict CSP blocks

every external host). And the capability system exists because of that block —

each replacement is better, needing no API key.

works ✅ dies ❌ → replacement
URL state · localStorage · rich clipboard · file input · file download · copy-paste transform · single-file inline · format-inspector tools · tool remixing · CDN libs (measured loading — undocumented, inline anyway) cross-origin fetchmcp (viewer creds) · browser LLM calls w/ key → sample (no key) · remote images → base64 · Pyodide → untested

Payload versioning (from Excalidraw)#

  1. Version every payload, check on read (◆LOOP v1 is that field).
  2. One restore/validate layer for ALL inbound payloads, whatever the source.
  3. Strip ephemeral state before persisting — verdicts/notes yes; cursor,

scroll, open-lightbox-index no.

Agent side: one schema both directions (api.ts envelope / api.ts parse).

Storage by size, not habit#

localStorage sync ~5MB (verdicts, filters) · IndexedDB async unbounded (blobs,

5MB — wins in production even when slower, it never freezes the UI) · db

capability when state must outlive the browser or be shared.

Filters & selection — the bits usually missed#

Collapse long facet lists w/ "Show more" · search box inside a facet past ~15

values · a selected-filters list with individual removal · adaptive facets ·

selection count must include rows outside the viewport (the classic

select-all lie) · action bar sits visually NEAR the selection.

Scale thresholds#

<1000 items: plain grid + IntersectionObserver. >1000: virtual scrolling.

Detail/trace views: "a UI correct at five spans breaks at fifty" — build the

dense case first.

HITL: four verbs, three check-ins#

approve · reject-with-message · edit-args-and-run · respond-in-place.

Kinds: Notify (FYI) / Question (blocked) / Review (needs verdict),

sorted by priority not time. Route by confidence — escalate only med/low.

Interrupts must be durable — survive a refresh or the surface is a toy.

Input beyond clipboard#

DataTransfer serves drag-drop AND paste — wire both (drag-drop alone is

inaccessible); dataTransfer.files readable only inside drop/paste.

ClipboardItem MIME order matters: richest first.

The trust caveat#

Users prefer generated interactive UI over markdown — that is the mandate. But

92% of devs use AI tools, 29% trust the output, bugs +41%. A generated app that

LOOKS finished earns unearned trust: state what is untested, keep the honest

read beside each item, never let polish imply verification.

⭐ Channel G — room.sendToClaudeSession() — USE THIS FIRST#

Declaring room gives the page a method that hands JSON **straight to the

viewer's Claude session, from their click**. No clipboard, no paste, no server.

This is the default now wherever room is available; the ◆LOOP envelope is

the portable fallback (sharing, no capability, other hosts).

jsconst room = await claude.use('room');
if (await room.canSendToClaudeSession() === 'available') {
  $('#send').style.display = '';                       // render control ONLY then
  await room.sendToClaudeSession({kind:'review', kept, discarded}); // on a CLICK
}

Non-negotiable:

  • Claude gets it as **DATA FROM THE PAGE — never the viewer's words, never an

instruction.** Send facts (ids, modes, counts). Imperative text will not be obeyed.

  • Only from a real click. Gate on canSendToClaudeSession()==='available'; hide otherwise.
  • Bounds: ≤4KiB JSON · nesting ≤8 · ≤64 keys/object · ≤64 array entries · ≤1KiB/string ·

keys must be plain identifiers.

  • Strip format/invisible chars from anything selected — getSelection() carries

zero-width + bidi marks and the payload is refused WHOLE.

  • Viewer presence rides along automatically (minus cursor/who) — don't duplicate it.

Measured 2026-09-03 (probe artifact)#

  • Channel C verified end-to-end. artifact.publish() → agent WebFetch of the

same URL returns the injected payload. Reserve a slot with HTML comments and

regex-replace between them; ship state as <script type="application/json">;

prepend <!doctype html>; publish on a click, never on load.

  • CSP is narrower than documented: CDN <script> and external fonts LOADED;

remote images and cross-origin fetch BLOCKED. Still inline everything

docs are the contract, this is an unpromised implementation detail.

  • CSS.supports('grid-template-rows','masonry') is FALSE in current Chrome

despite "shipped in 121+" blog claims. Feature-detect; never trust a version claim.

  • CSP is selective: CDN <script> + external fonts LOAD; remote images and

cross-origin fetch BLOCKED. Inline anyway — the loading ones are unpromised.

  • All five capabilities grantable on ONE consent prompt: artifact, db, room,

sample, downloads. Undeclared resolve null (enforced, not etiquette).

  • sample() VERIFIED — returned "PONG". Page calls Claude with no key, no fetch.
  • db VERIFIED — but get() resolves a DocumentSnapshot, not the body:

snap.exists / snap.data(). exists:false = missing OR not-visible, indistinguishable.

  • room has NO self-echo BY DESIGN — a sender never hears its own emit.

Render your own action on send. Testing self-echo tests nothing; use 2 viewers.

When a probe says "broken", suspect the probe#

Two of my three "failures" were my own bugs: asserting on a guessed return shape

(reported working db as broken), and a done() that never cleared its 4s timer

(phantom contradicting rows). Read the .d.ts before asserting; clear your timers.

An instrument that reports a false negative retires a working capability.

Not this skill#

  • Local file:// review pages with provenance cards → snappy-review-pages
  • Visual design calibration → artifact-design
  • Declaring db/room/sample/mcp → load artifact-capabilities first

<!-- SKILL-INDEX-START -->

[snappy-artifact-loop Index]|root: ~/.claude/skills/snappy-artifact-loop|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}

<!-- SKILL-INDEX-END -->

Used by#

Nothing in the collection names this skill.

<!-- SNAPPY-CONTRACT-VERBS-START -->

Contract verbs#

Generated from api.ts HAND_CONTRACT. Do not hand-edit this block.

Verb Contract arguments Effect First call
channels read npx tsx ~/.claude/skills/snappy-artifact-loop/api.ts channels
choose opts-json? read npx tsx ~/.claude/skills/snappy-artifact-loop/api.ts choose
envelope kind, payload-json, page-key? read npx tsx ~/.claude/skills/snappy-artifact-loop/api.ts envelope <kind> '["<payload>"]'
kit kind, page-key read npx tsx ~/.claude/skills/snappy-artifact-loop/api.ts kit <kind> <page-key>
parse text? read npx tsx ~/.claude/skills/snappy-artifact-loop/api.ts parse

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

# snappy-artifact-loop — loader

**A published Artifact is an input device, and you are its backend.** Do not
build a static output document when the page could collect judgment you act on.

## Pick the channel first — it is the design decision

| Channel | Page → you | You → page | Verified | Use when |
|---|---|---|---|---|
| **clipboard** | human pastes `◆LOOP` block | — | ✅ | **DEFAULT.** Review/rating/approval. No capability, no consent, no billing. |
| **comments** | `Artifact action:"comments"` | `action:"reply"` | ✅ | threaded prose; reply lands only on human-activated threads |
| **selfpublish** | page writes state into own HTML → you `WebFetch` the URL | redeploy same path | ✅ **VERIFIED 2026-09-03** | close the loop with no human carrying the payload |
| **sample** | page asks Claude inline | — | untested | judgment needed *during* the interaction; viewer pays |
| **db** | page-side realtime | ⚠ `write_db`/`read_db` absent 2026-09-03 | ⚠ | multi-session state — verify agent-side tools BEFORE promising a loop |
| **room** | ephemeral; you can join as kind `"agent"` | emit on admin topic | untested | live multi-viewer presence |

Never declare a capability you do not use — each is a consent prompt, and `mcp`
bars public sharing.

## Envelope contract (channel A)

```
◆LOOP v1 kind=<slug> page=<key>
{"items":[{"id":"…","verdict":"…","note":"…","meta":{…}}]}
◆END
```

- One envelope per page, one copy button, at the end.
- `kind` names **the action to take** — you branch on it.
- Every item carries the **addressable id/URL**, not just a verdict.
- Include what you cannot re-derive (refs used, round, prompt hash).

## Commands

```bash
S=~/.claude/skills/snappy-artifact-loop/api.ts
npx tsx $S channels --json                 # decision table
npx tsx $S choose '{"prose":true}' --json  # recommend a channel
npx tsx $S kit draft-review q3-launch     # embeddable copy-back block
npx tsx $S envelope gallery-rating '{"items":[…]}'
pbpaste | npx tsx $S parse --json          # read a pasted envelope
```

Page markup the kit expects:
```html
<div data-loop-item="r4-selfie" data-loop-meta='{"round":4}'>
  <button data-loop-verdict="fire">🔥</button>
  <textarea data-loop-note></textarea>
</div>
```

## Artifact hard constraints

- Strict CSP: **no** CDN, external fonts, remote images, or `fetch`. Inline all;
  images as `data:` URIs.
- 16MB ceiling, base64 inflates ~33% → `sips -Z 620 -s formatOptions 55`.
- No `<!doctype>/<html>/<head>/<body>` — the platform wraps it.
- Theme-aware: `prefers-color-scheme` **plus** `:root[data-theme=…]` overrides.
- Same file path = same URL on redeploy. Stable `favicon`, always.
- `navigator.clipboard` works here (real https origin), unlike `file://` pages —
  keep the `execCommand` fallback anyway.

## Judgment-surface defaults

1. **Comparison beats absolute judgment** — lay options side by side.
2. **Blind when you have a stake** — randomise sides, blur arm labels until the vote.
3. **Provenance behind `<details>`** — visible is noise, one click is trust.
4. **Capture judgment as data**, not prose that scrolls away.
5. **State the honest read** next to each item; do not hide known defects.


## Application grade — what makes it feel like an app

Ranked by feel-per-line. A review widget is not an application.

1. **Keyboard-first + auto-advance** — Lightroom's review loop: `P` keep / `X`
   discard / `U` clear, cursor advances itself. Highest-throughput judgment loop
   known; applies to drafts, model outputs, candidates, search results alike.
2. **⌘K command palette** — every action typeable; doubles as the feature list.
3. **Roving tabindex** — one cell `tabindex=0`, rest `-1`, arrows move focus AND the 0.
4. **Undo/redo command pattern** — `{do,undo}` objects on a stack. Makes rating fearless.
5. **Faceted rail with live counts** — the counts are the affordance.
6. **Bulk select + floating action bar** — appears on selection, near the selection.
7. **URL as state** — filters + open item in the hash; every view linkable.
8. **Optimistic UI** — paint before persisting, always.
9. **Progressive disclosure** — detail behind `<details>` (NN/g: −55% cognitive load).
10. **View Transitions** — `document.startViewTransition`, 3 lines, kills the document feel.
11. **IntersectionObserver lazy load**, `rootMargin:400px` — data: URIs still cost decode.
12. **CSS Grid Lanes masonry** behind `@supports`, plain-grid fallback.

## Richer clipboard

Text fence is the floor. Async Clipboard web custom formats ride alongside:
`'web application/x-artifact-loop+json'` (Chromium 104+, ≤100 formats).
**Always write `text/plain` too** — it is what survives a paste into chat today.

## Landscape (do not reinvent)

- **MCP Apps** (2026-01-26, `@modelcontextprotocol/ext-apps`) — interactive UI
  inside the chat, bidirectional. Use when the surface belongs in the conversation.
- **AG-UI** — ~16 agent↔frontend event types incl. `STATE_DELTA` (JSON Patch). Steal the vocabulary.
- **Agent Inbox** (LangChain) — HITL as a priority inbox; check-ins are
  Notify / Question / Review; approvals are approve / reject-with-msg / **edit-args** / respond.
- **Simon Willison's 150+ HTML tools** — closest prior art, but his env allows CDN +
  CORS fetch and artifacts allow NEITHER. Do not copy his dependency patterns.
- Users **strongly prefer generated interactive UI over markdown** (GenUI research).

Reference implementation: `gallery_app.html` — 61 Xano images, every pattern above, ~400 lines vanilla.


## ⭐⭐ CHANNEL H — the page calls the AGENT (strongest; no capability needed)

`sample()` gives a page **a model**. `bridge.py` gives it **you** — MCP servers,
filesystem, image pipeline, skills, this session's context. Different category:
`sample` reshapes what the page has; the bridge **changes the world**.
VERIFIED 2026-09-03 end-to-end: a page asked for a new image to be generated →
the real pipeline fired → URL returned in 24s. No model call could have done that.

```
page ─POST /ask─▶ bridge 127.0.0.1:8787 ─▶ queue/req-<id>.json
                                                 │ your Monitor fires → chat
page ◀─long-poll─ bridge ◀─POST /answer─ you (any tool)
```
`bridge.py` ships in this skill. `GET /` serves the app (same-origin),
`/pending` shows what's waiting, `/answer` releases the poll.

Rules: **bind 127.0.0.1 only** (unauthenticated hole into an agent) · latency =
your turn cadence, long-poll ~240s · serve the app FROM the bridge · probe with
~1.5s AbortController so no-bridge falls through fast · **launch DETACHED
(`nohup … &`, never a bare `&`)** — a bare background job dies when the harness
kills its parent shell, and the page then long-polls 240s with nobody home,
which reads as "the app broke" · the bridge dies with the session, so say so in
any handoff · show which engine resolved, so a dead backend never looks live.

Monitor loop must use `find`, NOT a glob — **zsh kills an unmatched glob**
(`no matches found`, exit 1, monitor dead):
```bash
while true; do find "$Q" -name 'req-*.json' -type f 2>/dev/null | while read -r f; do
  [ -e "$f.seen" ] || { touch "$f.seen"; printf 'ASK '; cat "$f"; echo; }; done; sleep 1; done
```

## THE THREE-ENGINE SHIM — write this, never bind to one engine

1. `claude.use('sample')` (artifact host) → 2. **local bridge** (you, with tools)
→ 3. BYOK key. Degrade quietly, show which engine resolved.

## ⚠️ ASSERT EVERY STRING EDIT

A `replace()` that matches nothing changes nothing and reports success. Cost an
hour: `LLMdirectly` vs `LLM directly`, one space, silent no-op, page shipped
without its bridge probe while the build printed "wired".

```python
assert old in s, f"NOT FOUND → {label}"
assert s.count(old)==1, f"AMBIGUOUS {s.count(old)}× → {label}"
```
Then **verify the shipped artifact, not the source you think you edited**:
`grep -c` the built file AND `curl | grep -c` what is actually served, and
`node --check` the extracted script. Same shape as: the "dead" phrase live in 6
sites, and `db` reported broken from a guessed return type. **An unverified
write that reports success is worse than a failure — a failure tells you.**

## ⚠️ WHERE THE POWER COMES FROM — read this first

`window.claude` is **host-injected middleware**, not a web feature. The published
artifact's raw HTML carries a `__FRAME_PREAMBLE` naming the capability bundles the
platform loads. **A local file has no `window.claude` because nobody injected one.**

Artifact and local file are near-exact COMPLEMENTS:

| | artifact | your own HTML |
|---|---|---|
| sample/db/room/mcp/downloads/sendToClaudeSession | ✅ injected | ❌ absent |
| cross-origin `fetch`, remote images, your API keys | ❌ CSP | ✅ unrestricted |

**So always write against a shim, never `claude.use()` directly** — one file then
runs in both worlds:

```js
async function getSampler(){
  const s = window.claude?.use ? await window.claude.use('sample') : null;
  if (s) return {via:'host', fn: async q => (await s(q)).text};
  const key = localStorage.getItem('llm:key');           // local: your own middleware
  if (!key) return null;                                  // degrade, don't break
  return {via:'byok', fn: async q => { /* fetch OpenRouter / Anthropic */ }};
}
```
Anthropic direct from a browser needs `anthropic-dangerous-direct-browser-access: true`;
OpenRouter needs nothing special.

## Simon's HTML-tool patterns in an artifact

**9 of his 14 work unchanged.** Only network-touching ones die (strict CSP blocks
every external host). And the capability system exists *because* of that block —
each replacement is better, needing no API key.

| works ✅ | dies ❌ → replacement |
|---|---|
| URL state · localStorage · rich clipboard · file input · file download · copy-paste transform · single-file inline · format-inspector tools · tool remixing · **CDN libs (measured loading — undocumented, inline anyway)** | cross-origin `fetch` → **`mcp`** (viewer creds) · browser LLM calls w/ key → **`sample`** (no key) · remote images → base64 · Pyodide → untested |

## Payload versioning (from Excalidraw)

1. **Version every payload**, check on read (`◆LOOP v1` is that field).
2. **One restore/validate layer** for ALL inbound payloads, whatever the source.
3. **Strip ephemeral state before persisting** — verdicts/notes yes; cursor,
   scroll, open-lightbox-index no.
Agent side: one schema both directions (`api.ts envelope` / `api.ts parse`).

## Storage by size, not habit

localStorage sync ~5MB (verdicts, filters) · IndexedDB async unbounded (blobs,
>5MB — wins in production even when slower, it never freezes the UI) · `db`
capability when state must outlive the browser or be shared.

## Filters & selection — the bits usually missed

Collapse long facet lists w/ "Show more" · search box inside a facet past ~15
values · **a selected-filters list with individual removal** · adaptive facets ·
**selection count must include rows outside the viewport** (the classic
select-all lie) · action bar sits visually NEAR the selection.

## Scale thresholds

<1000 items: plain grid + IntersectionObserver. >1000: virtual scrolling.
Detail/trace views: *"a UI correct at five spans breaks at fifty"* — build the
dense case first.

## HITL: four verbs, three check-ins

approve · reject-with-message · **edit-args-and-run** · respond-in-place.
Kinds: **Notify** (FYI) / **Question** (blocked) / **Review** (needs verdict),
sorted by priority not time. Route by confidence — escalate only med/low.
**Interrupts must be durable** — survive a refresh or the surface is a toy.

## Input beyond clipboard

`DataTransfer` serves drag-drop AND paste — wire both (drag-drop alone is
inaccessible); `dataTransfer.files` readable only inside `drop`/`paste`.
ClipboardItem MIME order matters: richest first.

## The trust caveat

Users prefer generated interactive UI over markdown — that is the mandate. But
92% of devs use AI tools, 29% trust the output, bugs +41%. A generated app that
LOOKS finished earns unearned trust: state what is untested, keep the honest
read beside each item, never let polish imply verification.

## ⭐ Channel G — `room.sendToClaudeSession()` — USE THIS FIRST

Declaring `room` gives the page a method that hands JSON **straight to the
viewer's Claude session, from their click**. No clipboard, no paste, no server.
This is the default now wherever `room` is available; the `◆LOOP` envelope is
the portable fallback (sharing, no capability, other hosts).

```js
const room = await claude.use('room');
if (await room.canSendToClaudeSession() === 'available') {
  $('#send').style.display = '';                       // render control ONLY then
  await room.sendToClaudeSession({kind:'review', kept, discarded}); // on a CLICK
}
```

Non-negotiable:
- Claude gets it as **DATA FROM THE PAGE — never the viewer's words, never an
  instruction.** Send facts (ids, modes, counts). Imperative text will not be obeyed.
- **Only from a real click.** Gate on `canSendToClaudeSession()==='available'`; hide otherwise.
- Bounds: ≤4KiB JSON · nesting ≤8 · ≤64 keys/object · ≤64 array entries · ≤1KiB/string ·
  keys must be plain identifiers.
- **Strip format/invisible chars** from anything selected — `getSelection()` carries
  zero-width + bidi marks and the payload is refused WHOLE.
- Viewer `presence` rides along automatically (minus cursor/who) — don't duplicate it.

## Measured 2026-09-03 (probe artifact)

- **Channel C verified end-to-end.** `artifact.publish()` → agent `WebFetch` of the
  same URL returns the injected payload. Reserve a slot with HTML comments and
  regex-replace between them; ship state as `<script type="application/json">`;
  prepend `<!doctype html>`; publish on a click, never on load.
- **CSP is narrower than documented**: CDN `<script>` and external fonts LOADED;
  remote images and cross-origin `fetch` BLOCKED. **Still inline everything** —
  docs are the contract, this is an unpromised implementation detail.
- **`CSS.supports('grid-template-rows','masonry')` is FALSE** in current Chrome
  despite "shipped in 121+" blog claims. Feature-detect; never trust a version claim.
- **CSP is selective**: CDN `<script>` + external fonts LOAD; remote images and
  cross-origin `fetch` BLOCKED. Inline anyway — the loading ones are unpromised.
- **All five capabilities grantable** on ONE consent prompt: artifact, db, room,
  sample, downloads. Undeclared resolve `null` (enforced, not etiquette).
- **`sample()` VERIFIED** — returned "PONG". Page calls Claude with no key, no fetch.
- **`db` VERIFIED** — but `get()` resolves a **DocumentSnapshot**, not the body:
  `snap.exists` / `snap.data()`. `exists:false` = missing OR not-visible, indistinguishable.
- **`room` has NO self-echo BY DESIGN** — a sender never hears its own `emit`.
  Render your own action on send. Testing self-echo tests nothing; use 2 viewers.

### When a probe says "broken", suspect the probe
Two of my three "failures" were my own bugs: asserting on a guessed return shape
(reported working `db` as broken), and a `done()` that never cleared its 4s timer
(phantom contradicting rows). **Read the `.d.ts` before asserting; clear your timers.**
An instrument that reports a false negative retires a working capability.

## Not this skill

- Local `file://` review pages with provenance cards → `snappy-review-pages`
- Visual design calibration → `artifact-design`
- Declaring `db`/`room`/`sample`/`mcp` → load `artifact-capabilities` first

<!-- SKILL-INDEX-START -->
[snappy-artifact-loop Index]|root: ~/.claude/skills/snappy-artifact-loop|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}
<!-- SKILL-INDEX-END -->

## Used by

Nothing in the collection names this skill.

<!-- SNAPPY-CONTRACT-VERBS-START -->
## Contract verbs

Generated from `api.ts` `HAND_CONTRACT`. Do not hand-edit this block.

| Verb | Contract arguments | Effect | First call |
|---|---|---|---|
| `channels` | — | `read` | `npx tsx ~/.claude/skills/snappy-artifact-loop/api.ts channels` |
| `choose` | `opts-json?` | `read` | `npx tsx ~/.claude/skills/snappy-artifact-loop/api.ts choose` |
| `envelope` | `kind`, `payload-json`, `page-key?` | `read` | `npx tsx ~/.claude/skills/snappy-artifact-loop/api.ts envelope <kind> '["<payload>"]'` |
| `kit` | `kind`, `page-key` | `read` | `npx tsx ~/.claude/skills/snappy-artifact-loop/api.ts kit <kind> <page-key>` |
| `parse` | `text?` | `read` | `npx tsx ~/.claude/skills/snappy-artifact-loop/api.ts parse` |

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