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}
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 -->
One file then runs as a published artifact, as a local app backed by the agent,
or standalone with a key — and shows which engine it got.
The bug that cost an hour: an unverified replace()#
The bridge probe did not reach the page because a str.replace() matched
nothing, changed nothing, and the build printed "three-engine shim wired"
anyway. Cause: an earlier edit had produced LLMdirectly (no space) and the
later replace targeted LLM directly. **One character. Silent no-op. False
success.**
This is the same shape as three other failures in this session: a phrase the
docs called dead while it fired from six live sites; a db capability reported
broken because the return type was guessed; this. So:
**Every programmatic string edit asserts that it matched, and asserts the
match is unique.** Then verify the artifact you actually ship, not the source
you think you edited.
pythondef fix(old, new, label):
assert old in s, f"NOT FOUND → {label}"
assert s.count(old) == 1, f"AMBIGUOUS {s.count(old)}× → {label}"return s.replace(old, new, 1)
The assertions paid for themselves immediately: two of three "fixes" aborted
because they had already been applied in a previous round. Without them I would
have written garbage over working code. **An unverified write that reports
success is worse than a failure — a failure tells you.**
End every build with a check on the shipped file, not the builder:
bashgrep -c 'localhost:8787/health' app.html # in the built file?
curl -s http://localhost:8787/ | grep -c ... # in what is actually served?
node --check <(extract-the-script app.html) # does it even parse?
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-ax
Drive any Mac app through the Accessibility tree (AXUIElement) the way the shipping tools actually do it —...
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-database
Snappy Database -- single source of truth for the data layer that backs every snappy-* skill
snappy-docs
THE DEFAULT for writing to Notion -- the Snappy stack's Notion primitive over the REST API (api.notion.com/v1)
snappy-nightshift
The overnight orchestration operating system: one orchestrator drives a repo toward 100% all night with bui...
snappy-review-pages
Local HTML review pages with provenance and a copy-back feedback loop
snappy-sync
Cross-machine file sharing between MacBook Pro and Mac Mini
snappy-telegram
Telegram Bot API channel for Snappy: direct calls to api.telegram.org (no Xano middleware) to send text, ph...
snappy-tool-design
Contract-first ergonomics lint for AI-operated skills, unlike snappy-artifact-loop which manages implementa...
snappy-video
Video and audio processing pipeline for Snappy, run on the Mac Mini via SSH (caption-video.sh wrapper aroun...
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-xano-mcp
THE EXISTING, DEPLOYED Snappy MCP server: the Cloudflare Worker exposing the Snappy Xano API (email, calend...
<!-- SNAPPY-NEAR-NEIGHBOURS-END -->
---
name: snappy-artifact-loop
description: >
Build published Artifacts as I/O devices where the AGENT is the backend, not
as static output documents. Covers the six round-trip channels (clipboard
envelope, artifact comments, self-publish + WebFetch, sample, db, room), the
decision table for picking one, the copy-back envelope contract that makes a
paste machine-parseable, and what is verified vs unverified on each channel.
Use when asked for: an artifact, an HTML page or tool, "agent as backend", a
page that talks back, a review or approval surface, "no dedicated backend", a
monitor that updates a page, an artifact loop, a round-trip page.
NOT local file:// review pages with a copy-all feedback bar (see
snappy-review-pages). NOT visual design of the page (see artifact-design).
Triggers: artifact loop, agent as backend, round-trip page, copy back to
claude, make an artifact, page that talks back, no dedicated backend,
snappy-artifact-loop, artifact channels, envelope.
---
# snappy-artifact-loop
## The thesis
A published Artifact is not a document. It is an **input device** — and the
agent that generated it is its backend.
Every agent UI today assumes one direction: the agent produces a page, the
human reads it. But the agent can generate arbitrary frontend logic, and there
are channels that carry structured state back. That inverts the relationship:
the page becomes a surface for *collecting* human judgment in a form the agent
can act on deterministically, with **no server, no deploy, no dedicated
backend**.
Born 2026-09-03 from one framing: *"it turns you into the logic — you are the
back end. The context is pre-made, so when it comes back you know exactly what
to do with it."*
The state of the art (2026-09) is all about the *agent loop* — the agent calls
tools, tools return results. Nobody writes about the inverse: the page as the
agent's sense organ. That is the gap this skill covers.
## What this is for
Any workflow where an agent produces more than a person can read in a chat
window, and a judgment has to come back:
| situation | the surface |
|---|---|
| 40 draft headlines / subject lines / hooks | rate them side by side, keys only, verdicts return as data |
| a model's outputs across a prompt A/B | blind comparison, randomised sides, the winner comes back structured |
| a content calendar an agent drafted | approve / edit-args / reject per item, in one pass |
| a data migration's proposed changes | review the diff, send back exactly which rows to apply |
| generated media — images, thumbnails, video frames | keep/discard at one key per item, the keeps get acted on |
| a research sweep across 60 sources | filter and group by whatever question you think of *after* seeing it |
| anything you would otherwise paste into a spreadsheet | the page is the spreadsheet, and it talks back |
The common shape: **the agent can do the work but cannot make the call**, and
the call is expensive to collect over chat. A generated surface makes the call
cheap, then returns it in a form the agent can act on without asking follow-ups.
## The six channels
Pick ONE deliberately. The channel is the design decision; everything else is
detail.
| # | Channel | Page → Agent | Agent → Page | Persists | Verified |
|---|---------|--------------|--------------|----------|----------|
| **A** | **Clipboard envelope** | human pastes a fenced block | — | none | ✅ used in production all session |
| **B** | **Artifact comments** | `Artifact action:"comments"` | `action:"reply"` | yes | ✅ tool exists; threads must be human-activated |
| **C** | **Self-publish + WebFetch** | page writes state into its own HTML → agent `WebFetch`es the URL | redeploy same file path | yes (the page IS the record) | ✅ **VERIFIED END-TO-END 2026-09-03** — probe wrote its own results via `artifact.publish()`, agent read them back by URL |
| **D** | **`sample`** | page asks Claude inline, no agent round-trip | — | none | ✅ **VERIFIED 2026-09-03** — returned "PONG" |
| **E** | **`db`** | page-side reads/writes, realtime `onSnapshot` | *(agent-side `write_db`/`read_db` NOT available in this session)* | server-side | ✅ page-half VERIFIED (`get()`→`DocumentSnapshot`, use `.exists`/`.data()`); agent half still absent |
| **F** | **`room`** | ephemeral events; publishing session joins as kind `"agent"` | same | none | granted ✅; **no self-echo BY DESIGN** — needs 2 viewers to test |
| **G** | **`room.sendToClaudeSession()`** | **page hands JSON straight to the viewer's Claude, from a click** | — | none | ⭐ **THE ONE** — no clipboard, no server. Gate on `canSendToClaudeSession()` |
| **H** | **Local bridge (`bridge.py`)** | **page calls the running Claude Code session over `localhost` — WITH ITS TOOLS** | agent writes the answer file | none | ⭐⭐ **THE STRONGEST** — verified 2026-09-03. Only channel where the backend can actually *do* things |
### Choosing
- **Default to A.** Zero capabilities, works in every host, survives sharing,
no consent prompt, no billing. Most review/approval/rating work is A.
- **Reach for C** when you want the loop closed without a human carrying the
payload — the page records its own state, you read it back later. This is the
closest thing to "no dedicated backend" that is fully verified.
- **B** when the feedback is prose and threaded rather than structured.
- **D** when the page needs judgment *inline* and waiting for the agent's next
turn would break the interaction.
- **E/F** for multi-viewer or live work. Confirm the agent-side tools exist
before promising a loop — they were absent 2026-09-03.
**Never declare a capability you do not use.** Each one is a consent prompt, and
`mcp` bars public sharing outright.
## The envelope contract (channel A)
The whole value of A is that the paste is **machine-parseable**, not prose. The
page emits a fenced envelope; the agent greps for it and acts without asking
follow-up questions.
```
◆LOOP v1 kind=<slug> page=<page-key>
{ "items": [ {"id":"…","verdict":"…","note":"…"} ], "meta": {…} }
◆END
```
Rules that make it work:
1. **One envelope per page**, at the end, behind a single copy button. Never
make the reviewer assemble anything.
2. **`kind` names the action to take**, not the data shape — `gallery-rating`,
`ref-swap`, `shot-approval`. The agent branches on `kind`.
3. **Carry identity, not just verdict.** Every item needs the id/URL the agent
needs to act — a verdict with no addressable target is useless.
4. **Include what the agent cannot re-derive**: which refs produced an image,
which round it came from, the prompt hash. The paste should let the next
turn act without re-reading the whole session.
5. **JSON body, fenced by the ◆ markers.** Greppable, and survives being pasted
into a chat that mangles markdown.
`api.ts envelope` builds it; `api.ts parse` reads it back.
## Clipboard on claude.ai artifacts
Unlike local `file://` pages (see `snappy-review-pages`, where
`navigator.clipboard` is unreliable and the hidden-textarea `execCommand`
fallback is required), published artifacts run on a real https origin where
`navigator.clipboard.writeText` works. Keep the `execCommand` fallback anyway —
it is three lines and covers permission denial.
## Hard constraints (published artifacts)
- **CSP blocks the network SELECTIVELY.** Measured 2026-09-03: remote images and
cross-origin `fetch` are BLOCKED; CDN `<script>` and external fonts LOADED.
**Inline everything anyway** — the docs promise a blanket block, so the parts
that load are unpromised and can tighten without warning. There is no
workaround for images: embed them as `data:` URIs.
- **16MB ceiling**, and base64 inflates ~33%. Downscale aggressively: `sips -Z
620 -s formatOptions 55`. A 9-image page fits comfortably under 1.5MB.
- **No `<!doctype>`, `<html>`, `<head>`, `<body>`** in the file — the platform
wraps it. Write page content directly.
- **Theme-aware or it looks broken** for half the viewers: `@media
(prefers-color-scheme: dark)` *plus* `:root[data-theme=dark|light]`
overrides, because the in-app toggle stamps `data-theme` and must win.
- **Same file path = same URL** on redeploy. A different path mints a new
artifact. To update one from an earlier session, pass its `url`.
- **`favicon` is required** and must stay stable across redeploys — viewers
find the tab by its icon.
## Design defaults for judgment surfaces
These are what make a page a *machine* rather than a gallery. Carried over from
`snappy-review-pages` and re-validated on artifacts 2026-09-03:
1. **Comparison beats absolute judgment.** Humans are slow at "is this good"
and fast at "which is better." Lay options out side by side; that single
choice collapses most of the review cost.
2. **Blind by default when the agent has a stake.** Randomise left/right and
blur arm labels until the vote lands. An A/B where the agent's preferred arm
is labelled is not evidence.
3. **Provenance behind `<details>`.** Visible by default is noise; one click
away is trust. Every AI-generated item carries prompt, engine, settings,
refs, and a full-res link.
4. **Capture the judgment as data, not prose.** A rating that scrolls away
taught nothing. It goes into the envelope.
5. **State the honest read next to each item.** "the third paragraph drifts
off-brief here" in the caption beats a clean-looking page that hides the
defect. A review surface that flatters the work is worse than none.
## Workflow
**Inputs** — the artifacts under review + their real provenance (grep the
session transcript `~/.claude/projects/<dir>/*.jsonl` if it was not recorded;
never reconstruct provenance from memory).
**Build**
1. Pick the channel from the table. Say which one and why.
2. Downscale assets, base64 them, write the page to the scratchpad.
3. Load `artifact-design` for visual calibration if the page warrants it.
4. Emit the envelope block via `api.ts kit` (channel A) or wire the chosen
capability.
5. Publish with a stable `favicon` and a one-sentence `description`.
**Close the loop**
- A: the reviewer pastes → `api.ts parse` → branch on `kind` → act.
- C: `WebFetch` the artifact URL → read the embedded state block → act.
- B: `Artifact action:"comments"` → read threads → `action:"reply"`.
## Quick start
```bash
S=~/.claude/skills/snappy-artifact-loop/api.ts
npx tsx $S channels --json # the decision table
npx tsx $S kit draft-review q3-launch # copy-back JS+CSS block to paste into a page
npx tsx $S envelope gallery-rating '{"items":[{"id":"r4-selfie","verdict":"fire"}]}'
pbpaste | npx tsx $S parse --json # read a pasted envelope back
```
## Open questions (fold findings back in)
- Does `room`'s agent-kind admission actually deliver events to a Claude Code
session, or only to the publishing turn? Untested.
- Are `write_db`/`read_db` host-gated, or absent for this account? They were
not in the tool list 2026-09-03; channel E's agent half is blocked until this
is answered.
- Does `WebFetch` of an artifact URL return post-`publish()` state, or the
originally deployed HTML? Channel C's read half needs one real test.
## Related skills
| Skill | Why |
|---|---|
| `snappy-review-pages` | the local `file://` sibling — provenance cards, feedback widgets, `execCommand` clipboard |
| `artifact-design` | visual calibration; load before writing a page that warrants design investment |
| `artifact-capabilities` | authoritative contract for declaring `db`/`room`/`sample`/`mcp`; load before declaring any |
| `snappy-image` | generates the media these pages usually present |
---
# Part II — Application grade
Everything above builds a *review surface*. This part is about building an
**application**. Added 2026-09-03 after a 30-source sweep, prompted by the note
that a review page "still looks like what we already had — I want it to feel
like an application experience, something you're powering."
## The landscape, as of 2026-09
Worth knowing so you do not reinvent or mis-name things:
- **Generative UI (GenUI)** is the accepted term for a UI generated/controlled by
a model at runtime. Empirically, users **strongly prefer generated interactive
experiences over text/markdown output**. Known failure modes: inconsistency
between generations, latency, and hallucinated components.
- **MCP Apps** (official MCP extension, 2026-01-26, `@modelcontextprotocol/ext-apps`)
renders interactive UI *inside* the chat with bidirectional host↔component
messaging. Runs in Claude, VS Code, Goose. This is the productised version of
the same idea — reach for it when the surface belongs in the conversation.
- **AG-UI** is an open event protocol for agent↔frontend streaming: ~16 event
types over SSE/WebSocket, including `STATE_DELTA` (JSON Patch against shared
state) and lifecycle pause/resume. Good vocabulary to steal even when you are
not using the protocol.
- **Agent Inbox** (LangChain) models human-in-the-loop as an *inbox*, priority-
sorted, with three check-in types: **Notify · Question · Review**. Approval
verbs worth copying: approve / reject-with-message / **edit-args** / respond.
- **Simon Willison's HTML tools** (150+, nearly all LLM-written) is the closest
prior art for single-file tools. His environment differs in one direction each
way: he can `fetch` CORS APIs and call LLMs with his own key (artifacts
cannot); artifacts get host-injected capabilities he cannot have. CDN scripts
work in both, contrary to the docs.
Nobody in that sweep describes the page as an *input device for the agent*.
That inversion is still the open ground.
## What makes it feel like an app, not a page
Ranked by how much they change the feel per line of code.
1. **Keyboard-first, with auto-advance.** Lightroom's review loop is the
highest-throughput judgment interface ever shipped: `P` keep, `X` discard,
`U` clear, and the cursor **advances automatically**. One key per decision,
no mouse, no clicking "next". Whether the items are draft headlines, model
outputs, candidate thumbnails or search results, this is the single biggest
win available when a surface asks for many judgments.
2. **Command palette on ⌘K.** Every action reachable by typing. Linear's rule:
every common action has a shortcut and the palette is one keystroke away.
It also doubles as self-documentation — the palette IS the feature list.
3. **Roving tabindex** for grid navigation (WAI-ARIA APG): exactly one cell has
`tabindex="0"`, all others `-1`; arrows move focus and move the 0. One Tab
enters the grid instead of 61 tab stops.
4. **Undo/redo via the command pattern.** Every mutation is an object with
`do()`/`undo()` pushed on a stack. Cheap to implement, and it converts a
destructive surface into a safe one — people rate faster when mistakes are free.
5. **Faceted filters with live counts** in a rail. Counts are the affordance:
they tell you where the work is before you click.
6. **Bulk select + floating action bar** that appears only on selection and sits
visually near the selection. Shift/⌘-click to select, space to toggle.
7. **URL as state.** Filters and the open item live in the hash, so any view is
linkable and reload-safe — and the agent can hand back a deep link.
8. **Optimistic UI.** Never await anything before painting. Local state changes
render instantly; persistence happens after.
9. **Progressive disclosure.** Summary first, detail on demand (NN/g: up to 55%
cognitive-load reduction). Provenance goes behind `<details>`, never inline.
10. **View Transitions** (`document.startViewTransition`, all major browsers
2026) for filter/route changes. Three lines, and the surface stops feeling
like a document that reloads.
11. **IntersectionObserver lazy load** with `rootMargin` ~400px. Embedded
`data:` URIs still cost decode time; 60+ images will jank without it.
12. **CSS Grid Lanes** (`grid-template-rows: masonry`) behind `@supports`, with
a plain-grid fallback. ⚠️ `CSS.supports` returned **FALSE in Chrome
2026-09-03**, despite every "shipped in Chrome 121+" write-up. The
`@supports` guard is the only reason the reference app's layout survived.
## Richer clipboard: web custom formats
The `◆LOOP` text fence is the floor, not the ceiling. The Async Clipboard API
supports **web custom formats** — prefix a MIME type with `web ` and it rides
unsanitised alongside the plain-text version:
```js
await navigator.clipboard.write([new ClipboardItem({
'text/plain': new Blob([fenced], {type:'text/plain'}),
'web application/x-artifact-loop+json': new Blob([json],
{type:'web application/x-artifact-loop+json'}),
})]);
```
Chromium 104+, up to 100 custom formats. **Always write `text/plain` too** —
that is what actually survives a paste into chat today. Treat the custom format
as forward-compatibility, not the transport.
## Reference implementation
`gallery_app.html` (2026-09-03) — 61 media assets pulled live from a production
database, 3.7MB, every pattern above in ~400 lines of vanilla JS. Built from the
brief: *"the assets are already there — project them onto a page with the logic
and everything, and make it feel like a convincing app."*
## Judgment-surface defaults (updated)
Fold these into the five in Part I:
6. **Randomise presentation order** in any pairwise comparison — this is the
standard RLHF mitigation for position bias, not a nicety.
7. **Show the counts** — rated / unrated / picked / rejected, always visible.
The remaining count is what makes someone finish.
8. **Never block on the agent.** The page must be fully usable with the agent
absent; agent-powered features light up if and when they resolve.
---
# Part III — Simon's 14 patterns, and everything else from the sweep
## Why some HTML-tool patterns die in an artifact (and what replaces them)
The blunt claim "Simon's stuff doesn't work here" is wrong. **Ten of his
fourteen patterns work unchanged.** What breaks is anything that needs to PULL
DATA over the network: cross-origin `fetch` and remote images. CDN `<script>`
and external fonts were measured LOADING (2026-09-03) — undocumented, so do not
build on them.
And here is the part worth internalising: **the capability system exists
precisely because the network is blocked.** Every blocked pattern has an
artifact-native replacement that is *better*, because it needs no API key and
leaks no credential.
| # | Simon's pattern | In an artifact | Replacement |
|---|---|---|---|
| 1 | URL state | ✅ works | — |
| 2 | localStorage | ✅ works | (`db` if it must outlive the browser) |
| 3 | Rich clipboard, multi-format | ✅ works | — |
| 4 | `<input type=file>`, no upload | ✅ works | — |
| 5 | Generate + download a file | ✅ works | `downloads` capability for a native save |
| 6 | Copy-paste transform | ✅ works | — |
| 7 | **CORS public APIs** | ❌ cross-origin `fetch` blocked (measured) | `mcp` — viewer's connectors, their credentials, no token in the page |
| 8 | **Direct LLM calls w/ API key in localStorage** | ❌ blocked, and a bad idea | **`sample`** — Claude with no key, no fetch, viewer consents |
| 9 | Single-file, inline everything | ✅ mandatory here | — |
| 10 | CDN libraries | ⚠️ **measured LOADING** — undocumented; inline anyway | inline the source |
| 11 | Debug tools to inspect formats | ✅ works | — |
| 12 | Pyodide | ❓ untested — its loader is a CDN `<script>`, which does load | — |
| 13 | **WebAssembly** | ⚠️ only if the binary is inlined as a `data:`/base64 blob | — |
| 14 | Tool remixing (feed old source to the LLM) | ✅ works | — |
Practical consequence: **an artifact is a harder target than a local HTML tool,
but a richer one.** No key management, no CORS roulette, and `sample`/`mcp` give
a page abilities Simon's tools cannot have.
## Payload versioning — steal this from Excalidraw
Excalidraw has survived years of schema drift with three moves. Apply all three
to the `◆LOOP` envelope and to any `db`/`artifact` state:
1. **Write a version into every payload** and check it on read. `◆LOOP v1` is
that field — bump it when the shape changes, and keep a migration path.
2. **Route every inbound payload through one restore/validate layer** before it
touches your logic — regardless of source (clipboard, file, URL, capability).
One `restore()` that migrates + validates beats defensive checks everywhere.
3. **Strip ephemeral state before persisting.** Excalidraw's
`cleanAppStateForExport()` drops selection, cursor and scroll. Your envelope
should carry verdicts and notes, never `cursor`, `lbIdx`, or which filter
chip was lit.
Same discipline on the agent side: **one schema, both directions.** The Zod/JSON
Schema pattern — one definition generates the contract and validates the reply —
is the structured-output equivalent, and `api.ts parse` is where it lives here.
## Storage: pick by size and blocking, not habit
- **localStorage** — synchronous, ~5MB, blocks the main thread. Fine for
verdicts, filters, drafts. What the gallery app uses.
- **IndexedDB** — async, non-blocking, effectively unbounded. Required once you
cache images/blobs or exceed ~5MB. Note the counterintuitive finding: for
<100KB localStorage reads faster, but IndexedDB still wins in production
because it never freezes the UI.
- **`db` capability** — when state must outlive the browser profile, be shared,
or be readable by another session.
## Filtering and selection, done properly
Faceted-search practice that the simple rail in Part II does not yet cover:
- **Collapse long facet lists** to N values with "Show more".
- **Search box inside the facet** once values exceed ~15.
- **A selected-filters list** with individual removal + "clear all" — users lose
track of what is applied and blame the tool for wrong results.
- **Adaptive facets** — the available facets change as the collection narrows.
Bulk-selection practice:
- **Show the count of everything selected, including rows outside the viewport.**
This is the classic bug: "Select all" selects the page, the user believes it
selected the set.
- **The action bar must sit visually near the selection.** If it floats far
away, people hesitate before clicking.
## Scale thresholds — do not pre-optimise
- **<1000 items**: plain grid + `IntersectionObserver` lazy load. Measure first;
virtual scrolling adds real complexity.
- **>1000 items**: virtual scrolling — render only the visible window, constant
DOM size. Libraries handle 1M rows; hand-rolled is a day's work.
- **Trace/detail views**: the failure mode is scale, not correctness — *"a UI
that looks correct with five spans may break with fifty."* Build the dense
case first, then the empty state.
## Human-in-the-loop, richer than approve/reject
From the agent-framework practice, worth adopting in any approval surface:
- **Four verbs, not two**: approve · reject-with-message · **edit the arguments
and run** · respond-in-place (the human's text becomes the tool result).
"Edit args" is the one everyone forgets and the one people want most.
- **Three check-in kinds**: **Notify** (FYI), **Question** (blocked, needs an
answer), **Review** (proposed action, needs a verdict). Label them; they carry
different urgency and belong sorted by priority, not by time.
- **Confidence-based routing** — act autonomously on high confidence, escalate
medium/low. The point of a HITL surface is to shrink what reaches it.
- **Durable interrupts.** A pause must survive a refresh: state in localStorage
or `db`, never in a JS variable. If the page reloads and the decision is lost,
the surface is a toy.
## Input paths beyond the clipboard
- **`DataTransfer` is shared by drag-drop AND paste.** Wire one handler to both:
drag-drop alone is inaccessible, and paste is what people actually reach for.
`dataTransfer.files` is only readable inside `drop` and `paste` events.
- **ClipboardItem MIME order matters** — richest format first; some targets take
the first type they recognise.
## Two governance facts worth carrying
- Users **strongly prefer generated interactive UI over markdown**. That is the
mandate for building these at all.
- But: **92% of developers use AI coding tools daily, 29% trust the output, and
bug rates rose 41% post-adoption** — review processes lag adoption. Applied
here: a generated app that *looks* finished earns unearned trust. Say plainly
what is untested, keep the honest read next to each item, and never let polish
imply verification.
---
# Part IV — Measured, not assumed (2026-09-03)
A probe artifact ran 20 tests in a real Chrome session and wrote the results into its
own HTML; the agent read them back by URL. **Two documented claims turned out to
be wrong, and the loop itself was proven.**
## Channel C works. Full stop.
`artifact.publish()` → `WebFetch` of the same URL returned the injected JSON.
That is a complete agent⇄page round trip with **no human carrying a payload and
no server**. One human click to start it, nothing after. This is the "no
dedicated backend" pattern, confirmed.
Implementation notes from the working probe:
- Reserve a slot with HTML comments (`<!--RESULTS-START--> … <!--RESULTS-END-->`)
and regex-replace between them. Never serialize the live DOM.
- Ship the payload as `<script type="application/json" id="…">` — survives the
round trip cleanly and is trivial to locate on fetch.
- Prepend `<!doctype html>` to `documentElement.outerHTML` when republishing.
- Publish **on a click**, never on load.
## The CSP is narrower than the docs say — do not exploit that
| probe | documented | **measured** |
|---|---|---|
| CDN `<script>` (jsdelivr) | blocked | **ALLOWED — loaded** |
| External font (fonts.gstatic.com) | blocked | **ALLOWED — loaded** |
| Remote image | blocked | BLOCKED ✓ |
| `fetch()` cross-origin | blocked | BLOCKED ✓ |
So `script-src`/`font-src` admit at least some CDNs while `img-src`/`connect-src`
do not. **Keep inlining everything anyway.** The documentation is the contract;
this is an implementation detail that can tighten without warning, and a page
that silently loses its library is worse than one that never had it. Recorded so
the next agent does not have to re-measure — not as permission.
The one place it matters: **the Simon table in Part III is too pessimistic.**
His CDN pattern (#10) does currently load. Treat it as "works today, unpromised"
rather than "dies".
## Feature detection beat the spec
`CSS.supports('grid-template-rows','masonry')` → **false** in current Chrome,
despite the shipping claims in the 2026 write-ups. The `@supports` guard in the
gallery app is the only reason its layout is intact. **Never take a "shipped in
version X" blog claim as license to skip the guard.**
## Undeclared capabilities resolve null — as designed
`artifact` was declared and granted. `db`, `room`, `sample`, `mcp`, `downloads`
all returned `null` **because they were not declared** — that is correct
behaviour, not evidence they are unavailable to this account. Two consequences:
1. The "declare only what you use" rule is enforced by the platform, not just etiquette.
2. **Their availability is still unmeasured.** To learn whether they work,
declare them in a probe and re-run. Do not record them as unavailable.
---
# Part V — The channel that ends the clipboard (measured 2026-09-03, run 2)
## `room.sendToClaudeSession()` — the page hands data straight to Claude
Declaring `room` exposes a method that is exactly what this whole skill was
groping toward:
```js
const room = await claude.use('room');
if (await room.canSendToClaudeSession() === 'available') {
await room.sendToClaudeSession({ picked: ids, mode: 'review' }); // from a CLICK
}
```
**The viewer clicks; Claude — beside the page, in their session — receives the
object.** No clipboard, no paste, no fetch, no server. This obsoletes the
`◆LOOP` envelope as the *default* wherever `room` is available. Keep the
envelope as the portable fallback (sharing, no capability, other hosts).
Non-negotiables from the type contract:
- **Claude receives it as DATA FROM THE PAGE — never as the viewer's words,
never as an instruction.** Design the payload as facts (ids, modes, flags,
counts), and never write imperative text into it hoping Claude obeys.
- **Only from a real click.** Not on load, not on a timer.
- **Gate on `canSendToClaudeSession() === "available"`** and render the control
only then; hide it on any other value or rejection (older platforms reject
`capability_removed` / `invalid_argument`).
- **Bounds**: ≤4 KiB JSON, nesting ≤8, ≤64 keys per object, ≤64 entries per
array, ≤1 KiB per string, keys must be plain identifiers.
- **Strip format characters** from anything the viewer selected —
`getSelection()` text routinely carries zero-width and bidi marks, and the
payload is refused whole if any survive.
- The viewer's `presence` rides along automatically (minus `cursor`/`who`) —
do not duplicate it in the payload.
## `sample()` works — the page can call Claude inline
Verified: `await sample('Reply with exactly the word: PONG')` returned
`"PONG"`. A published page can get model judgment **without an API key, without
`fetch`, without the agent's turn**. Viewer consents once and pays.
Together these two make the earlier framing too timid. The page is not only an
input device — it can *think* mid-interaction and *hand structured results back*
on its own.
## All five capabilities are grantable
`artifact`, `db`, `room`, `sample`, `downloads` all resolved non-null once
declared, on **one** consent prompt covering the set. Undeclared ones resolve
`null` — the platform enforces "declare only what you use".
## Corrections — my probe was wrong twice, the platform was not
Recorded because the failure mode matters more than the result:
1. **`db` did NOT fail.** `set()` and `get()` both completed without throwing;
my assertion was written against the wrong shape. `get()` resolves a
**`DocumentSnapshot`**, not the document body:
```js
const snap = await db.doc('probe/ping').get();
if (snap.exists) { const body = snap.data(); /* … */ } // NOT snap.stamp
```
`exists === false` covers both "missing" and "you cannot see it" —
deliberately indistinguishable. **Read the `.d.ts` before asserting on a
capability's return shape; a guessed shape reports a working platform as broken.**
2. **`room`'s missing self-echo is correct behaviour, not a failure.** A sender
does not hear its own `emit`. The contract says so plainly: render your own
action on send and skip same-tab echoes. Testing "did my own message come
back" tests nothing — you need a second viewer.
3. **Two phantom `timeout` rows** came from my own probe: `done()` resolved the
promise but never cleared the 4 s timer, so a late `add()` appended a
contradicting row. **A probe that races itself produces evidence that looks
like a platform inconsistency.** Clear your timers.
The general lesson, and the reason this section exists: **an instrument that
reports a false negative is worse than no instrument** — it retires a working
capability. When a probe says "broken", suspect the probe first.
---
# Part VI — Where the power actually comes from (2026-09-03)
On opening a local copy and finding the engine dead: *"so it only works as an
artifact — we'd have to provide that same middleware ourselves?"* — **exactly
right, and this is the load-bearing fact of the whole skill.**
## `window.claude` is host-injected middleware, not a web feature
Fetching a published artifact's raw HTML shows a `__FRAME_PREAMBLE` the platform
prepends, naming the capability bundles it will load:
```
window.__FRAME_PREAMBLE = {"capabilities":{
"artifact":"artifact.*.js","db":"db.*.js","room":"room.*.js",
"sample":"sample.*.js","mcp":"mcp.*.js","downloads":"downloads.*.js", …}}
```
The host frames the page and hands it `window.claude.use()`. **A file on disk,
an S3 bucket, or your own server has no `window.claude` because nobody injected
one.** Nothing about the page is different — the *environment* is.
So the split is not artifact-vs-HTML. It is:
| | published artifact | your own HTML |
|---|---|---|
| `sample`, `db`, `room`, `mcp`, `downloads`, `sendToClaudeSession` | ✅ host-injected | ❌ **nobody injects them** |
| cross-origin `fetch` | ❌ CSP | ✅ **unrestricted** |
| remote images | ❌ CSP | ✅ unrestricted |
| your own API keys | pointless (no `fetch`) | ✅ workable |
**They are near-exact complements.** The artifact gets a brain but no network;
the local file gets a network but no brain. Which means:
## Providing the middleware yourself is ~20 lines
Because a local file has no CSP, it can call an LLM directly — Simon's pattern
#8. Write the engine as a **capability shim** with the same shape as
`claude.use('sample')`, so ONE app runs in both places:
```js
async function getSampler(){
// 1. host-injected (published artifact)
const s = window.claude?.use ? await window.claude.use('sample') : null;
if (s) return {via:'host', fn: async q => (await s(q)).text};
// 2. your own middleware (local file, your key, direct fetch)
const key = localStorage.getItem('llm:key');
if (!key) return null;
return {via:'byok', fn: async q => {
const r = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method:'POST', headers:{'Content-Type':'application/json',
Authorization:'Bearer '+key},
body: JSON.stringify({model:'anthropic/claude-sonnet-4.5',
messages:[{role:'user',content:q}]})});
return (await r.json()).choices[0].message.content;
}};
}
```
Anthropic direct also works from a browser but needs
`anthropic-dangerous-direct-browser-access: true`; OpenRouter needs nothing
special. **Write every agent-backed page against a shim like this**, never
against `claude.use()` directly — then the same file is a shareable artifact
*and* a local tool, and degrades to saved-lenses-only when neither engine
resolves.
## What this means for the whole skill
The seven channels split by who provides the middleware:
- **Host-provided** (artifact only): `sample`, `db`, `room`,
`sendToClaudeSession`, `mcp`, `downloads`, `artifact.publish`.
- **Environment-provided** (works anywhere): clipboard envelope, file
import/export, URL state, localStorage.
- **You-provided** (local file, your key): a `sample` equivalent, plus anything
else you can reach with `fetch` — including your own backend.
**Design against the shim, declare the capability when publishing, and ship one
file that works in both worlds.** That is the durable answer; everything else in
this skill is detail hanging off it.
---
# Part VII — Channel H: the page calls the AGENT, not a model (2026-09-03)
Cutting through several messages of overcomplication: *"can it just call you?
Why does it need an API key — why can't it talk back to you directly?"*
**It can.** This is the strongest channel in the skill and it needs no platform
capability at all.
## Why this is different from every other channel
`sample()` gives a page **a model**. The bridge gives it **the agent** — this
session, with the MCP servers, the filesystem, `s3cmd`, the image pipeline, the
skills, and the whole conversation's context. The difference is not latency or
cost, it is *category*:
- `sample("write me a filter")` → text comes back.
- bridge `"render a new hero image for this section"` → **the generation
pipeline actually fires** and a URL comes back. Or `"which rows in the
database are missing a value here?"` → the agent queries and answers.
A page backed by `sample` can reshape what it already has. A page backed by the
bridge can change the world.
## Architecture (working implementation ships as `bridge.py`)
```
page ──POST /ask──▶ bridge (127.0.0.1:8787) ──▶ bridge_queue/req-<id>.json
│
agent's Monitor fires → lands in chat
│
page ◀──long-poll──── bridge ◀──POST /answer──── agent (any tool it likes)
```
- `GET /` serves the app, so the page is **same-origin** with its own backend.
- `POST /ask` → writes a request file, long-polls (240 s) for the answer.
- `GET /pending` → what the agent is being asked, for inspection.
- `POST /answer` → the agent's reply; deletes the request, releases the poll.
- `ThreadingHTTPServer`, CORS open, bound to **127.0.0.1 only**.
Agent side: `Monitor` on the queue directory. Each new request arrives as a
chat event; answer it with a `curl` to `/answer`.
## Rules
1. **Bind to 127.0.0.1, never 0.0.0.0.** This is an unauthenticated hole into an
agent with filesystem and network access. It must not leave the machine.
2. **Latency is the agent's turn cadence**, not milliseconds. Long-poll ~240 s
and show a spinner. If the session ends, the bridge answers nothing — the
page must degrade, not hang forever.
3. **Serve the app from the bridge** (`GET /`) so origin and backend match; no
CORS class of bug, and the probe is trivially reachable.
4. **Probe with a short timeout** (~1.5 s via `AbortController`) so a page with
no bridge falls through instantly instead of stalling on load.
5. **Launch it DETACHED — `nohup … & disown`, never a bare `&`.** A backgrounded
process inherits its parent's fate: start the bridge inside a shell that the
harness later kills (a finished test, a timed-out task) and the bridge dies
with it. Observed 2026-09-03. The failure is silent and misleading — the page
does not error, it long-polls the full 240 s and times out with nobody home,
so it reads as "the app broke" rather than "the server is gone."
6. **The bridge dies with the session.** State that in any handoff. Restart is
`nohup python3 bridge.py &` plus re-arming the queue monitor; without the
monitor the server answers nothing even though `/health` looks fine.
7. **Make liveness visible in the page.** A dead-backend page that looks
identical to a live one is the worst version of this. Probe on load, show
which engine resolved, and let a click re-probe.
## The three-engine shim — write this, always
Never bind a page to one engine. Try in order, degrade quietly:
```js
async function getEngine(){
const s = window.claude?.use ? await window.claude.use('sample') : null; // 1. artifact host
if (s) return {via:'host', fn: async q => (await s(q)).text};
try { // 2. the AGENT
const c=new AbortController(); const t=setTimeout(()=>c.abort(),1500);
const h=await fetch('http://localhost:8787/health',{signal:c.signal}); clearTimeout(t);
if (h.ok) return {via:'bridge', fn: async q => {
const r=await fetch('http://localhost:8787/ask',{method:'POST',
headers:{'Content-Type':'application/json'},body:JSON.stringify({q})});
const d=await r.json(); if(!r.ok) throw new Error(d.error); return d.text; }};
} catch(e){}
const key = localStorage.getItem('llm:key'); // 3. BYOK
return key ? {via:'byok', fn: /* fetch OpenRouter/Anthropic */} : null;
}
```
One file then runs as a published artifact, as a local app backed by the agent,
or standalone with a key — and shows which engine it got.
## The bug that cost an hour: an unverified `replace()`
The bridge probe did not reach the page because a `str.replace()` matched
nothing, changed nothing, and the build printed **"three-engine shim wired"**
anyway. Cause: an earlier edit had produced `LLMdirectly` (no space) and the
later replace targeted `LLM directly`. **One character. Silent no-op. False
success.**
This is the same shape as three other failures in this session: a phrase the
docs called dead while it fired from six live sites; a `db` capability reported
broken because the return type was guessed; this. So:
> **Every programmatic string edit asserts that it matched, and asserts the
> match is unique.** Then verify the artifact you actually ship, not the source
> you think you edited.
```python
def fix(old, new, label):
assert old in s, f"NOT FOUND → {label}"
assert s.count(old) == 1, f"AMBIGUOUS {s.count(old)}× → {label}"
return s.replace(old, new, 1)
```
The assertions paid for themselves immediately: two of three "fixes" aborted
because they had already been applied in a previous round. Without them I would
have written garbage over working code. **An unverified write that reports
success is worse than a failure — a failure tells you.**
End every build with a check on the shipped file, not the builder:
```bash
grep -c 'localhost:8787/health' app.html # in the built file?
curl -s http://localhost:8787/ | grep -c ... # in what is actually served?
node --check <(extract-the-script app.html) # does it even parse?
```
## Monitors: zsh kills an unmatched glob
`for f in req-*.json` is fine in bash (passes the literal through) and **fatal
in zsh** — `no matches found`, exit 1, monitor dead the first second the queue is
empty. Use `find` in any monitor loop:
```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
```
<!-- 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-ax` | Drive any Mac app through the Accessibility tree (AXUIElement) the way the shipping tools actually do it —... |
| `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-database` | Snappy Database -- single source of truth for the data layer that backs every snappy-* skill |
| `snappy-docs` | THE DEFAULT for writing to Notion -- the Snappy stack's Notion primitive over the REST API (api.notion.com/v1) |
| `snappy-nightshift` | The overnight orchestration operating system: one orchestrator drives a repo toward 100% all night with bui... |
| `snappy-review-pages` | Local HTML review pages with provenance and a copy-back feedback loop |
| `snappy-sync` | Cross-machine file sharing between MacBook Pro and Mac Mini |
| `snappy-telegram` | Telegram Bot API channel for Snappy: direct calls to api.telegram.org (no Xano middleware) to send text, ph... |
| `snappy-tool-design` | Contract-first ergonomics lint for AI-operated skills, unlike snappy-artifact-loop which manages implementa... |
| `snappy-video` | Video and audio processing pipeline for Snappy, run on the Mac Mini via SSH (caption-video.sh wrapper aroun... |
| `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-xano-mcp` | THE EXISTING, DEPLOYED Snappy MCP server: the Cloudflare Worker exposing the Snappy Xano API (email, calend... |
<!-- SNAPPY-NEAR-NEIGHBOURS-END -->
api.ts
#!/usr/bin/env npx tsx
/**
* snappy-artifact-loop — the agent is the backend.
*
* Build published Artifacts as I/O devices: emit a copy-back envelope from a
* page, parse the pasted envelope back into structured data, and pick the right
* round-trip channel for the job.
*
* Primitive-safe: plain tsx + no network + no MCP. Runs identically under
* Claude Code, snappy-shell, pi, and raw shell.
*/
import { realpathSync } from "node:fs";
// Spec A1: every skill imports the credential loader. This skill needs no
// credentials (it is pure string work), but the import keeps it uniform and
// ready if a channel ever needs an authenticated read-back.
import { env } from "../snappy-settings/load.ts";
import { refusalTable, printRefusal, refuseCli, RefusedError, isRefusedError } from "../snappy-settings/refusal-codes.ts";
void env;
export const ENVELOPE_VERSION = 1;
const OPEN = "◆LOOP";
const CLOSE = "◆END";
export type Channel = {
id: string;
name: string;
pageToAgent: string;
agentToPage: string;
persists: boolean;
capability: string | null;
verified: "yes" | "partial" | "untested";
use: string;
};
/** The six round-trip channels. The channel IS the design decision. */
export function channels(): Channel[] {
return [
{
id: "clipboard", name: "Clipboard envelope",
pageToAgent: "human pastes a fenced ◆LOOP block", agentToPage: "—",
persists: false, capability: null, verified: "yes",
use: "DEFAULT. Review, rating, approval. No capability, no consent prompt, no billing, survives sharing.",
},
{
id: "comments", name: "Artifact comments",
pageToAgent: 'Artifact action:"comments"', agentToPage: 'action:"reply"',
persists: true, capability: null, verified: "yes",
use: "Threaded prose feedback. Reply only lands on threads a human activated.",
},
{
id: "selfpublish", name: "Self-publish + WebFetch",
pageToAgent: "page writes state into its own HTML; agent WebFetches the URL",
agentToPage: "redeploy the same file path",
persists: true, capability: "artifact", verified: "partial",
use: "Closes the loop with no human carrying the payload. The page IS the record.",
},
{
id: "sample", name: "Inline Claude (sample)",
pageToAgent: "page asks Claude directly, no agent turn", agentToPage: "—",
persists: false, capability: "sample", verified: "untested",
use: "Page needs judgment inline. Viewer pays; first call prompts consent.",
},
{
id: "db", name: "Shared store (db)",
pageToAgent: "page-side reads/writes, realtime onSnapshot",
agentToPage: "write_db/read_db — NOT available 2026-09-03",
persists: true, capability: "db", verified: "partial",
use: "Multi-viewer or >1 session state. Confirm agent-side tools exist before promising a loop.",
},
{
id: "room", name: "Live room",
pageToAgent: 'ephemeral events; publishing session joins as kind "agent"',
agentToPage: "emit on an admin topic",
persists: false, capability: "room", verified: "untested",
use: "Presence and moments between viewers open RIGHT NOW. Nothing persists.",
},
];
}
/** Recommend a channel from the shape of the task. */
export function chooseChannel(opts: {
needsPersistence?: boolean;
multiViewer?: boolean;
inlineJudgment?: boolean;
humanCanPaste?: boolean;
prose?: boolean;
}): { channel: string; why: string } {
if (opts.inlineJudgment) {
return { channel: "sample", why: "page needs an answer during the interaction; an agent turn would break it" };
}
if (opts.multiViewer) {
return { channel: "room", why: "multiple viewers open at once — but confirm agent-side admission first" };
}
if (opts.prose) {
return { channel: "comments", why: "threaded prose beats a structured envelope for freeform notes" };
}
if (opts.needsPersistence && opts.humanCanPaste === false) {
return { channel: "selfpublish", why: "loop must close without a human carrying the payload" };
}
return { channel: "clipboard", why: "default — zero capabilities, no consent prompt, works everywhere" };
}
/** Build a machine-parseable copy-back envelope. */
export function envelope(kind: string, payload: unknown, pageKey = ""): string {
if (!kind || !/^[a-z0-9][a-z0-9-]*$/.test(kind)) {
throw new Error(`invalid kind "${kind}" — use a lowercase slug like gallery-rating`);
}
const head = `${OPEN} v${ENVELOPE_VERSION} kind=${kind}${pageKey ? ` page=${pageKey}` : ""}`;
return `${head}\n${JSON.stringify(payload, null, 2)}\n${CLOSE}`;
}
/** Parse a pasted envelope back into structured data. Throws if absent/invalid. */
export function parseEnvelope(text: string): { kind: string; page: string; version: number; payload: any } {
const re = new RegExp(`${OPEN}\\s+v(\\d+)\\s+kind=([a-z0-9-]+)(?:\\s+page=(\\S+))?\\s*\\n([\\s\\S]*?)\\n${CLOSE}`);
const m = text.match(re);
if (!m) throw new Error(`no ${OPEN} envelope found in input`);
const [, v, kind, page = "", body] = m;
let payload: any;
try {
payload = JSON.parse(body);
} catch (e) {
throw new Error(`envelope body is not valid JSON: ${(e as Error).message}`);
}
return { kind, page, version: Number(v), payload };
}
/**
* The embeddable copy-back block for channel A: styles + collector + one copy
* button that emits the envelope. Paste before the end of the artifact body.
*
* Markup contract on the page:
* <div data-loop-item="r4-selfie" data-loop-meta='{"round":4}'>…</div>
* inside it: buttons [data-loop-verdict="fire"] and a <textarea data-loop-note>.
*/
export function clipboardKit(kind: string, pageKey: string): string {
return `
<div id="loopbar">
<span id="loopcount">0 rated</span>
<button id="loopcopy" type="button">Copy for Claude</button>
</div>
<style>
#loopbar{position:sticky;bottom:0;display:flex;gap:12px;align-items:center;justify-content:flex-end;
padding:10px 14px;margin-top:24px;background:var(--c,#fff);border-top:1px solid var(--l,#e3e1db)}
#loopcount{color:var(--m,#6b6b73);font-size:.85rem;font-variant-numeric:tabular-nums}
#loopcopy{padding:8px 15px;border-radius:6px;border:1px solid var(--a,#b4471f);
background:var(--a,#b4471f);color:#fff;font:inherit;cursor:pointer}
#loopcopy.ok{background:transparent;color:var(--a,#b4471f)}
[data-loop-verdict].on{background:var(--a,#b4471f);color:#fff;border-color:var(--a,#b4471f)}
</style>
<script>
(function(){
var KIND=${JSON.stringify(kind)}, PAGE=${JSON.stringify(pageKey)}, KEY='loop:'+PAGE+':v${ENVELOPE_VERSION}';
var state={};
try{ state=JSON.parse(localStorage.getItem(KEY)||'{}'); }catch(e){ state={}; }
function count(){
var n=Object.keys(state).filter(function(k){return state[k].verdict||state[k].note;}).length;
document.getElementById('loopcount').textContent=n+' rated';
}
function save(){ try{ localStorage.setItem(KEY,JSON.stringify(state)); }catch(e){} count(); }
function rec(id){ return (state[id]=state[id]||{}); }
document.querySelectorAll('[data-loop-item]').forEach(function(el){
var id=el.getAttribute('data-loop-item');
el.querySelectorAll('[data-loop-verdict]').forEach(function(b){
if(state[id]&&state[id].verdict===b.getAttribute('data-loop-verdict')) b.classList.add('on');
b.addEventListener('click',function(){
rec(id).verdict=b.getAttribute('data-loop-verdict');
el.querySelectorAll('[data-loop-verdict]').forEach(function(x){x.classList.toggle('on',x===b);});
save();
});
});
var ta=el.querySelector('[data-loop-note]');
if(ta){
if(state[id]&&state[id].note) ta.value=state[id].note;
ta.addEventListener('input',function(){ rec(id).note=ta.value; save(); });
}
});
document.getElementById('loopcopy').addEventListener('click',function(){
var items=[];
document.querySelectorAll('[data-loop-item]').forEach(function(el){
var id=el.getAttribute('data-loop-item'), s=state[id];
if(!s||(!s.verdict&&!s.note)) return;
var o={id:id};
if(s.verdict) o.verdict=s.verdict;
if(s.note) o.note=s.note;
var meta=el.getAttribute('data-loop-meta');
if(meta){ try{ o.meta=JSON.parse(meta); }catch(e){} }
items.push(o);
});
var body=JSON.stringify({items:items},null,2);
var txt='${OPEN} v${ENVELOPE_VERSION} kind='+KIND+' page='+PAGE+'\\n'+body+'\\n${CLOSE}';
var done=function(){
var b=document.getElementById('loopcopy');
b.textContent='Copied — paste to Claude'; b.classList.add('ok');
setTimeout(function(){ b.textContent='Copy for Claude'; b.classList.remove('ok'); },2200);
};
if(navigator.clipboard&&navigator.clipboard.writeText){
navigator.clipboard.writeText(txt).then(done,fallback);
} else fallback();
function fallback(){
var t=document.createElement('textarea');
t.value=txt; t.style.position='fixed'; t.style.opacity='0';
document.body.appendChild(t); t.select();
try{ document.execCommand('copy'); done(); }catch(e){ prompt('Copy this:',txt); }
document.body.removeChild(t);
}
});
count();
})();
</script>`.trim();
}
// ---------------------------------------------------------------- CLI
function out(json: boolean, human: string, data: unknown) {
console.log(json ? JSON.stringify(data, null, 2) : human);
}
async function main(argv: string[]) {
const json = argv.includes("--json");
const args = argv.filter((a) => a !== "--json");
const [cmd, ...rest] = args;
switch (cmd) {
case "channels": {
const c = channels();
out(json, c.map((x) =>
`${x.id.padEnd(12)} ${x.verified.padEnd(8)} ${x.capability ?? "-"}\n ${x.use}`
).join("\n"), c);
return 0;
}
case "choose": {
const opts = rest[0] ? JSON.parse(rest[0]) : {};
const r = chooseChannel(opts);
out(json, `${r.channel} — ${r.why}`, r);
return 0;
}
case "envelope": {
const [kind, payload, pageKey = ""] = rest;
if (!kind || !payload) throw new RefusedError("missing_argument", "envelope needs the kind and the JSON payload; contract slice verbs.envelope.args = [kind, payload-json, page-key?]. usage: envelope <kind> '<json>' [pageKey]");
const e = envelope(kind, JSON.parse(payload), pageKey);
out(json, e, { envelope: e });
return 0;
}
case "parse": {
// TWO CONDITIONS, NOT ONE ⟨lane refusals-2, 2026-09-09⟩. Measured bare
// first call: `✗ usage: parse '<text>' | pbpaste | parse` on stderr,
// nothing on stdout, exit 1 — and the same sentence for a caller who
// passed nothing and for a caller whose pipe was empty. They are
// different mistakes with different next moves, so they refuse apart.
const piped = rest[0] === undefined;
const text = rest[0] ?? await readStdin();
if (!text.trim()) {
throw piped
? new RefusedError("missing_argument", "parse needs the pasted envelope text, either as the argument or on stdin, and neither carried anything; contract slice verbs.parse.args = [text?]. usage: parse '<text>' | pbpaste | parse")
: new RefusedError("invalid_argument", "parse was given an argument that is empty or whitespace; contract slice verbs.parse.args = [text?]. Pass the envelope text itself, or pipe it: pbpaste | parse");
}
const p = parseEnvelope(text);
out(json,
`kind=${p.kind} page=${p.page} v${p.version}\n${JSON.stringify(p.payload, null, 2)}`, p);
return 0;
}
case "kit": {
const [kind, pageKey] = rest;
if (!kind || !pageKey) throw new RefusedError("missing_argument", "kit needs the kind and the page key; contract slice verbs.kit.args = [kind, page-key]. usage: kit <kind> <pageKey>");
const k = clipboardKit(kind, pageKey);
out(json, k, { kit: k });
return 0;
}
default: {
const usage =
"snappy-artifact-loop — the agent is the backend\n\n" +
" channels [--json] the six round-trip channels\n" +
" choose '<json opts>' [--json] recommend a channel\n" +
" envelope <kind> '<json>' [page] build a copy-back envelope\n" +
" parse ['<text>'] [--json] parse a pasted envelope (or stdin)\n" +
" kit <kind> <pageKey> embeddable copy-back block for a page\n";
if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") { console.log(usage); return 0; }
console.error(usage);
refuseCli("unknown_verb", `unknown verb "${cmd}"; contract slice verbs = [${Object.keys(HAND_CONTRACT.verbs).join(", ")}]`);
return 1;
}
}
}
function readStdin(): Promise<string> {
return new Promise((res) => {
let d = "";
if (process.stdin.isTTY) return res("");
process.stdin.setEncoding("utf8");
process.stdin.on("data", (c) => (d += c));
process.stdin.on("end", () => res(d));
});
}
/** 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-artifact-loop",
description: "Build published Artifacts as I/O devices where the AGENT is the backend, not as static output documents. Covers the six round-trip channels (clipboard envelope, artifact comments, self-publish + WebFetch, sample, db, room), the decision table for picking one, the copy-back envelope contract that makes a paste machine-parseable, and what is verified vs unverified on each channel. Use when asked for: an artifact, an HTML page or tool, \"agent as backend\", a page that talks back, a review or approval surface, \"no dedicated backend\", a monitor that updates a page, an artifact loop, a round-trip page. NOT local file:// review pages with a copy-all feedback bar (see snappy-review-pages). NOT visual design of the page (see artifact-design). Triggers: artifact loop, agent as backend, round-trip page, copy back to claude, make an artifact, page that talks back, no dedicated backend, snappy-artifact-loop, artifact channels, envelope.",
managed: false,
requires: [] as string[],
refusals: refusalTable("unknown_verb", "missing_argument", "invalid_argument"),
verbs: {
channels: {
args: [], effect: "read",
class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
},
choose: {
args: ["opts-json?"], effect: "read",
class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { "opts-json": { type: "string", description: "JSON object of channel options to choose a rendition from" } } },
},
envelope: {
args: ["kind","payload-json","page-key?"], effect: "read",
class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { kind: { type: "string", description: "Artifact kind the envelope wraps" }, "payload-json": { type: "string", description: "JSON payload placed inside the envelope" }, "page-key": { type: "string", description: "Page the envelope belongs to" } } },
},
kit: {
args: ["kind","page-key"], effect: "read",
class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { kind: { type: "string", description: "Artifact kind whose kit is returned" }, "page-key": { type: "string", description: "Page the kit is built for" } } },
},
parse: {
args: ["text?"], effect: "read",
class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { text: { type: "string", description: "Text to parse an envelope out of; omit to read stdin" } } },
},
},
} 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])}`) {
main(process.argv.slice(2))
.then((code) => process.exit(code))
// A refusal raised deep is printed ONCE, here, on stdout with exit 1;
// anything that is not a RefusedError stays an exception.
.catch((e) => {
if (isRefusedError(e)) { printRefusal(e.refusal); process.exit(1); }
console.error(`✗ ${e.message}`);
process.exit(1);
});
}
#!/usr/bin/env npx tsx
/**
* snappy-artifact-loop — the agent is the backend.
*
* Build published Artifacts as I/O devices: emit a copy-back envelope from a
* page, parse the pasted envelope back into structured data, and pick the right
* round-trip channel for the job.
*
* Primitive-safe: plain tsx + no network + no MCP. Runs identically under
* Claude Code, snappy-shell, pi, and raw shell.
*/
import { realpathSync } from "node:fs";
// Spec A1: every skill imports the credential loader. This skill needs no
// credentials (it is pure string work), but the import keeps it uniform and
// ready if a channel ever needs an authenticated read-back.
import { env } from "../snappy-settings/load.ts";
import { refusalTable, printRefusal, refuseCli, RefusedError, isRefusedError } from "../snappy-settings/refusal-codes.ts";
void env;
export const ENVELOPE_VERSION = 1;
const OPEN = "◆LOOP";
const CLOSE = "◆END";
export type Channel = {
id: string;
name: string;
pageToAgent: string;
agentToPage: string;
persists: boolean;
capability: string | null;
verified: "yes" | "partial" | "untested";
use: string;
};
/** The six round-trip channels. The channel IS the design decision. */
export function channels(): Channel[] {
return [
{
id: "clipboard", name: "Clipboard envelope",
pageToAgent: "human pastes a fenced ◆LOOP block", agentToPage: "—",
persists: false, capability: null, verified: "yes",
use: "DEFAULT. Review, rating, approval. No capability, no consent prompt, no billing, survives sharing.",
},
{
id: "comments", name: "Artifact comments",
pageToAgent: 'Artifact action:"comments"', agentToPage: 'action:"reply"',
persists: true, capability: null, verified: "yes",
use: "Threaded prose feedback. Reply only lands on threads a human activated.",
},
{
id: "selfpublish", name: "Self-publish + WebFetch",
pageToAgent: "page writes state into its own HTML; agent WebFetches the URL",
agentToPage: "redeploy the same file path",
persists: true, capability: "artifact", verified: "partial",
use: "Closes the loop with no human carrying the payload. The page IS the record.",
},
{
id: "sample", name: "Inline Claude (sample)",
pageToAgent: "page asks Claude directly, no agent turn", agentToPage: "—",
persists: false, capability: "sample", verified: "untested",
use: "Page needs judgment inline. Viewer pays; first call prompts consent.",
},
{
id: "db", name: "Shared store (db)",
pageToAgent: "page-side reads/writes, realtime onSnapshot",
agentToPage: "write_db/read_db — NOT available 2026-09-03",
persists: true, capability: "db", verified: "partial",
use: "Multi-viewer or >1 session state. Confirm agent-side tools exist before promising a loop.",
},
{
id: "room", name: "Live room",
pageToAgent: 'ephemeral events; publishing session joins as kind "agent"',
agentToPage: "emit on an admin topic",
persists: false, capability: "room", verified: "untested",
use: "Presence and moments between viewers open RIGHT NOW. Nothing persists.",
},
];
}
/** Recommend a channel from the shape of the task. */
export function chooseChannel(opts: {
needsPersistence?: boolean;
multiViewer?: boolean;
inlineJudgment?: boolean;
humanCanPaste?: boolean;
prose?: boolean;
}): { channel: string; why: string } {
if (opts.inlineJudgment) {
return { channel: "sample", why: "page needs an answer during the interaction; an agent turn would break it" };
}
if (opts.multiViewer) {
return { channel: "room", why: "multiple viewers open at once — but confirm agent-side admission first" };
}
if (opts.prose) {
return { channel: "comments", why: "threaded prose beats a structured envelope for freeform notes" };
}
if (opts.needsPersistence && opts.humanCanPaste === false) {
return { channel: "selfpublish", why: "loop must close without a human carrying the payload" };
}
return { channel: "clipboard", why: "default — zero capabilities, no consent prompt, works everywhere" };
}
/** Build a machine-parseable copy-back envelope. */
export function envelope(kind: string, payload: unknown, pageKey = ""): string {
if (!kind || !/^[a-z0-9][a-z0-9-]*$/.test(kind)) {
throw new Error(`invalid kind "${kind}" — use a lowercase slug like gallery-rating`);
}
const head = `${OPEN} v${ENVELOPE_VERSION} kind=${kind}${pageKey ? ` page=${pageKey}` : ""}`;
return `${head}\n${JSON.stringify(payload, null, 2)}\n${CLOSE}`;
}
/** Parse a pasted envelope back into structured data. Throws if absent/invalid. */
export function parseEnvelope(text: string): { kind: string; page: string; version: number; payload: any } {
const re = new RegExp(`${OPEN}\\s+v(\\d+)\\s+kind=([a-z0-9-]+)(?:\\s+page=(\\S+))?\\s*\\n([\\s\\S]*?)\\n${CLOSE}`);
const m = text.match(re);
if (!m) throw new Error(`no ${OPEN} envelope found in input`);
const [, v, kind, page = "", body] = m;
let payload: any;
try {
payload = JSON.parse(body);
} catch (e) {
throw new Error(`envelope body is not valid JSON: ${(e as Error).message}`);
}
return { kind, page, version: Number(v), payload };
}
/**
* The embeddable copy-back block for channel A: styles + collector + one copy
* button that emits the envelope. Paste before the end of the artifact body.
*
* Markup contract on the page:
* <div data-loop-item="r4-selfie" data-loop-meta='{"round":4}'>…</div>
* inside it: buttons [data-loop-verdict="fire"] and a <textarea data-loop-note>.
*/
export function clipboardKit(kind: string, pageKey: string): string {
return `
<div id="loopbar">
<span id="loopcount">0 rated</span>
<button id="loopcopy" type="button">Copy for Claude</button>
</div>
<style>
#loopbar{position:sticky;bottom:0;display:flex;gap:12px;align-items:center;justify-content:flex-end;
padding:10px 14px;margin-top:24px;background:var(--c,#fff);border-top:1px solid var(--l,#e3e1db)}
#loopcount{color:var(--m,#6b6b73);font-size:.85rem;font-variant-numeric:tabular-nums}
#loopcopy{padding:8px 15px;border-radius:6px;border:1px solid var(--a,#b4471f);
background:var(--a,#b4471f);color:#fff;font:inherit;cursor:pointer}
#loopcopy.ok{background:transparent;color:var(--a,#b4471f)}
[data-loop-verdict].on{background:var(--a,#b4471f);color:#fff;border-color:var(--a,#b4471f)}
</style>
<script>
(function(){
var KIND=${JSON.stringify(kind)}, PAGE=${JSON.stringify(pageKey)}, KEY='loop:'+PAGE+':v${ENVELOPE_VERSION}';
var state={};
try{ state=JSON.parse(localStorage.getItem(KEY)||'{}'); }catch(e){ state={}; }
function count(){
var n=Object.keys(state).filter(function(k){return state[k].verdict||state[k].note;}).length;
document.getElementById('loopcount').textContent=n+' rated';
}
function save(){ try{ localStorage.setItem(KEY,JSON.stringify(state)); }catch(e){} count(); }
function rec(id){ return (state[id]=state[id]||{}); }
document.querySelectorAll('[data-loop-item]').forEach(function(el){
var id=el.getAttribute('data-loop-item');
el.querySelectorAll('[data-loop-verdict]').forEach(function(b){
if(state[id]&&state[id].verdict===b.getAttribute('data-loop-verdict')) b.classList.add('on');
b.addEventListener('click',function(){
rec(id).verdict=b.getAttribute('data-loop-verdict');
el.querySelectorAll('[data-loop-verdict]').forEach(function(x){x.classList.toggle('on',x===b);});
save();
});
});
var ta=el.querySelector('[data-loop-note]');
if(ta){
if(state[id]&&state[id].note) ta.value=state[id].note;
ta.addEventListener('input',function(){ rec(id).note=ta.value; save(); });
}
});
document.getElementById('loopcopy').addEventListener('click',function(){
var items=[];
document.querySelectorAll('[data-loop-item]').forEach(function(el){
var id=el.getAttribute('data-loop-item'), s=state[id];
if(!s||(!s.verdict&&!s.note)) return;
var o={id:id};
if(s.verdict) o.verdict=s.verdict;
if(s.note) o.note=s.note;
var meta=el.getAttribute('data-loop-meta');
if(meta){ try{ o.meta=JSON.parse(meta); }catch(e){} }
items.push(o);
});
var body=JSON.stringify({items:items},null,2);
var txt='${OPEN} v${ENVELOPE_VERSION} kind='+KIND+' page='+PAGE+'\\n'+body+'\\n${CLOSE}';
var done=function(){
var b=document.getElementById('loopcopy');
b.textContent='Copied — paste to Claude'; b.classList.add('ok');
setTimeout(function(){ b.textContent='Copy for Claude'; b.classList.remove('ok'); },2200);
};
if(navigator.clipboard&&navigator.clipboard.writeText){
navigator.clipboard.writeText(txt).then(done,fallback);
} else fallback();
function fallback(){
var t=document.createElement('textarea');
t.value=txt; t.style.position='fixed'; t.style.opacity='0';
document.body.appendChild(t); t.select();
try{ document.execCommand('copy'); done(); }catch(e){ prompt('Copy this:',txt); }
document.body.removeChild(t);
}
});
count();
})();
</script>`.trim();
}
// ---------------------------------------------------------------- CLI
function out(json: boolean, human: string, data: unknown) {
console.log(json ? JSON.stringify(data, null, 2) : human);
}
async function main(argv: string[]) {
const json = argv.includes("--json");
const args = argv.filter((a) => a !== "--json");
const [cmd, ...rest] = args;
switch (cmd) {
case "channels": {
const c = channels();
out(json, c.map((x) =>
`${x.id.padEnd(12)} ${x.verified.padEnd(8)} ${x.capability ?? "-"}\n ${x.use}`
).join("\n"), c);
return 0;
}
case "choose": {
const opts = rest[0] ? JSON.parse(rest[0]) : {};
const r = chooseChannel(opts);
out(json, `${r.channel} — ${r.why}`, r);
return 0;
}
case "envelope": {
const [kind, payload, pageKey = ""] = rest;
if (!kind || !payload) throw new RefusedError("missing_argument", "envelope needs the kind and the JSON payload; contract slice verbs.envelope.args = [kind, payload-json, page-key?]. usage: envelope <kind> '<json>' [pageKey]");
const e = envelope(kind, JSON.parse(payload), pageKey);
out(json, e, { envelope: e });
return 0;
}
case "parse": {
// TWO CONDITIONS, NOT ONE ⟨lane refusals-2, 2026-09-09⟩. Measured bare
// first call: `✗ usage: parse '<text>' | pbpaste | parse` on stderr,
// nothing on stdout, exit 1 — and the same sentence for a caller who
// passed nothing and for a caller whose pipe was empty. They are
// different mistakes with different next moves, so they refuse apart.
const piped = rest[0] === undefined;
const text = rest[0] ?? await readStdin();
if (!text.trim()) {
throw piped
? new RefusedError("missing_argument", "parse needs the pasted envelope text, either as the argument or on stdin, and neither carried anything; contract slice verbs.parse.args = [text?]. usage: parse '<text>' | pbpaste | parse")
: new RefusedError("invalid_argument", "parse was given an argument that is empty or whitespace; contract slice verbs.parse.args = [text?]. Pass the envelope text itself, or pipe it: pbpaste | parse");
}
const p = parseEnvelope(text);
out(json,
`kind=${p.kind} page=${p.page} v${p.version}\n${JSON.stringify(p.payload, null, 2)}`, p);
return 0;
}
case "kit": {
const [kind, pageKey] = rest;
if (!kind || !pageKey) throw new RefusedError("missing_argument", "kit needs the kind and the page key; contract slice verbs.kit.args = [kind, page-key]. usage: kit <kind> <pageKey>");
const k = clipboardKit(kind, pageKey);
out(json, k, { kit: k });
return 0;
}
default: {
const usage =
"snappy-artifact-loop — the agent is the backend\n\n" +
" channels [--json] the six round-trip channels\n" +
" choose '<json opts>' [--json] recommend a channel\n" +
" envelope <kind> '<json>' [page] build a copy-back envelope\n" +
" parse ['<text>'] [--json] parse a pasted envelope (or stdin)\n" +
" kit <kind> <pageKey> embeddable copy-back block for a page\n";
if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") { console.log(usage); return 0; }
console.error(usage);
refuseCli("unknown_verb", `unknown verb "${cmd}"; contract slice verbs = [${Object.keys(HAND_CONTRACT.verbs).join(", ")}]`);
return 1;
}
}
}
function readStdin(): Promise<string> {
return new Promise((res) => {
let d = "";
if (process.stdin.isTTY) return res("");
process.stdin.setEncoding("utf8");
process.stdin.on("data", (c) => (d += c));
process.stdin.on("end", () => res(d));
});
}
/** 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-artifact-loop",
description: "Build published Artifacts as I/O devices where the AGENT is the backend, not as static output documents. Covers the six round-trip channels (clipboard envelope, artifact comments, self-publish + WebFetch, sample, db, room), the decision table for picking one, the copy-back envelope contract that makes a paste machine-parseable, and what is verified vs unverified on each channel. Use when asked for: an artifact, an HTML page or tool, \"agent as backend\", a page that talks back, a review or approval surface, \"no dedicated backend\", a monitor that updates a page, an artifact loop, a round-trip page. NOT local file:// review pages with a copy-all feedback bar (see snappy-review-pages). NOT visual design of the page (see artifact-design). Triggers: artifact loop, agent as backend, round-trip page, copy back to claude, make an artifact, page that talks back, no dedicated backend, snappy-artifact-loop, artifact channels, envelope.",
managed: false,
requires: [] as string[],
refusals: refusalTable("unknown_verb", "missing_argument", "invalid_argument"),
verbs: {
channels: {
args: [], effect: "read",
class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
},
choose: {
args: ["opts-json?"], effect: "read",
class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { "opts-json": { type: "string", description: "JSON object of channel options to choose a rendition from" } } },
},
envelope: {
args: ["kind","payload-json","page-key?"], effect: "read",
class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { kind: { type: "string", description: "Artifact kind the envelope wraps" }, "payload-json": { type: "string", description: "JSON payload placed inside the envelope" }, "page-key": { type: "string", description: "Page the envelope belongs to" } } },
},
kit: {
args: ["kind","page-key"], effect: "read",
class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { kind: { type: "string", description: "Artifact kind whose kit is returned" }, "page-key": { type: "string", description: "Page the kit is built for" } } },
},
parse: {
args: ["text?"], effect: "read",
class: "read", execution: "call", openWorld: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: { properties: { text: { type: "string", description: "Text to parse an envelope out of; omit to read stdin" } } },
},
},
} 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])}`) {
main(process.argv.slice(2))
.then((code) => process.exit(code))
// A refusal raised deep is printed ONCE, here, on stdout with exit 1;
// anything that is not a RefusedError stays an exception.
.catch((e) => {
if (isRefusedError(e)) { printRefusal(e.refusal); process.exit(1); }
console.error(`✗ ${e.message}`);
process.exit(1);
});
}
bridge.py
#!/usr/bin/env python3
"""CLAUDE BRIDGE — lets a local HTML page call the Claude Code session directly.
The page POSTs a question. The bridge drops it in a queue directory and
long-polls for an answer file. Claude (this session) watches the queue, does
whatever the question needs — with every tool it has — and writes the answer.
No API key. No cloud. The agent already running IS the backend.
python3 bridge.py & # Claude starts this
open http://localhost:8787 # the app talks to it
"""
import json, os, pathlib, time, threading, uuid, http.server, socketserver, urllib.parse
PORT = int(os.environ.get("BRIDGE_PORT", "8787"))
ROOT = pathlib.Path(__file__).parent
Q = ROOT / "bridge_queue"
Q.mkdir(exist_ok=True)
APP = ROOT / "lens.html"
WAIT = 240 # seconds a page will wait for Claude
POLL = 0.25
class H(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *a): # quiet
pass
def _cors(self):
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.send_header("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
def _send(self, code, body, ctype="application/json"):
b = body if isinstance(body, bytes) else body.encode()
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(b)))
self._cors()
self.end_headers()
self.wfile.write(b)
def do_OPTIONS(self):
self.send_response(204); self._cors(); self.end_headers()
def do_GET(self):
p = urllib.parse.urlparse(self.path).path
if p in ("/", "/index.html"):
if not APP.exists():
return self._send(404, "app not built yet", "text/plain")
return self._send(200, APP.read_bytes(), "text/html; charset=utf-8")
if p == "/health":
pend = len(list(Q.glob("req-*.json")))
return self._send(200, json.dumps({"ok": True, "pending": pend, "port": PORT}))
if p == "/pending": # for Claude: what is waiting
out = []
for f in sorted(Q.glob("req-*.json")):
try: out.append(json.loads(f.read_text()))
except Exception: pass
return self._send(200, json.dumps(out, indent=1))
return self._send(404, json.dumps({"error": "not found"}))
def do_POST(self):
p = urllib.parse.urlparse(self.path).path
n = int(self.headers.get("Content-Length") or 0)
try: body = json.loads(self.rfile.read(n) or b"{}")
except Exception: return self._send(400, json.dumps({"error": "bad json"}))
if p == "/answer": # Claude posts a reply
rid = body.get("id")
if not rid: return self._send(400, json.dumps({"error": "id required"}))
(Q / f"res-{rid}.json").write_text(json.dumps(body))
req = Q / f"req-{rid}.json"
if req.exists(): req.unlink()
return self._send(200, json.dumps({"ok": True}))
if p == "/ask": # the page asks
rid = uuid.uuid4().hex[:12]
rec = {"id": rid, "at": time.time(),
"kind": body.get("kind", "ask"),
"q": body.get("q", ""),
"context": body.get("context", {})}
(Q / f"req-{rid}.json").write_text(json.dumps(rec))
res = Q / f"res-{rid}.json"
t0 = time.time()
while time.time() - t0 < WAIT: # long-poll for Claude's answer
if res.exists():
d = res.read_text(); res.unlink()
return self._send(200, d)
time.sleep(POLL)
(Q / f"req-{rid}.json").unlink(missing_ok=True)
return self._send(504, json.dumps({"error": "claude did not answer in time",
"hint": "is the session awake?"}))
return self._send(404, json.dumps({"error": "not found"}))
class S(socketserver.ThreadingMixIn, http.server.HTTPServer):
daemon_threads = True
allow_reuse_address = True
if __name__ == "__main__":
for f in Q.glob("*.json"): f.unlink() # clean slate
print(f"bridge up http://localhost:{PORT} queue={Q}")
S(("127.0.0.1", PORT), H).serve_forever()
#!/usr/bin/env python3
"""CLAUDE BRIDGE — lets a local HTML page call the Claude Code session directly.
The page POSTs a question. The bridge drops it in a queue directory and
long-polls for an answer file. Claude (this session) watches the queue, does
whatever the question needs — with every tool it has — and writes the answer.
No API key. No cloud. The agent already running IS the backend.
python3 bridge.py & # Claude starts this
open http://localhost:8787 # the app talks to it
"""
import json, os, pathlib, time, threading, uuid, http.server, socketserver, urllib.parse
PORT = int(os.environ.get("BRIDGE_PORT", "8787"))
ROOT = pathlib.Path(__file__).parent
Q = ROOT / "bridge_queue"
Q.mkdir(exist_ok=True)
APP = ROOT / "lens.html"
WAIT = 240 # seconds a page will wait for Claude
POLL = 0.25
class H(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *a): # quiet
pass
def _cors(self):
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.send_header("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
def _send(self, code, body, ctype="application/json"):
b = body if isinstance(body, bytes) else body.encode()
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(b)))
self._cors()
self.end_headers()
self.wfile.write(b)
def do_OPTIONS(self):
self.send_response(204); self._cors(); self.end_headers()
def do_GET(self):
p = urllib.parse.urlparse(self.path).path
if p in ("/", "/index.html"):
if not APP.exists():
return self._send(404, "app not built yet", "text/plain")
return self._send(200, APP.read_bytes(), "text/html; charset=utf-8")
if p == "/health":
pend = len(list(Q.glob("req-*.json")))
return self._send(200, json.dumps({"ok": True, "pending": pend, "port": PORT}))
if p == "/pending": # for Claude: what is waiting
out = []
for f in sorted(Q.glob("req-*.json")):
try: out.append(json.loads(f.read_text()))
except Exception: pass
return self._send(200, json.dumps(out, indent=1))
return self._send(404, json.dumps({"error": "not found"}))
def do_POST(self):
p = urllib.parse.urlparse(self.path).path
n = int(self.headers.get("Content-Length") or 0)
try: body = json.loads(self.rfile.read(n) or b"{}")
except Exception: return self._send(400, json.dumps({"error": "bad json"}))
if p == "/answer": # Claude posts a reply
rid = body.get("id")
if not rid: return self._send(400, json.dumps({"error": "id required"}))
(Q / f"res-{rid}.json").write_text(json.dumps(body))
req = Q / f"req-{rid}.json"
if req.exists(): req.unlink()
return self._send(200, json.dumps({"ok": True}))
if p == "/ask": # the page asks
rid = uuid.uuid4().hex[:12]
rec = {"id": rid, "at": time.time(),
"kind": body.get("kind", "ask"),
"q": body.get("q", ""),
"context": body.get("context", {})}
(Q / f"req-{rid}.json").write_text(json.dumps(rec))
res = Q / f"res-{rid}.json"
t0 = time.time()
while time.time() - t0 < WAIT: # long-poll for Claude's answer
if res.exists():
d = res.read_text(); res.unlink()
return self._send(200, d)
time.sleep(POLL)
(Q / f"req-{rid}.json").unlink(missing_ok=True)
return self._send(504, json.dumps({"error": "claude did not answer in time",
"hint": "is the session awake?"}))
return self._send(404, json.dumps({"error": "not found"}))
class S(socketserver.ThreadingMixIn, http.server.HTTPServer):
daemon_threads = True
allow_reuse_address = True
if __name__ == "__main__":
for f in Q.glob("*.json"): f.unlink() # clean slate
print(f"bridge up http://localhost:{PORT} queue={Q}")
S(("127.0.0.1", PORT), H).serve_forever()
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",
"invalid_argument",
] as const satisfies readonly RefusalCode[];
test("snappy-artifact-loop: the refusal table declares exactly the codes this test names", () => {
assert.deepEqual(Object.keys(HAND_CONTRACT.refusals ?? {}).sort(), [...DECLARED].sort());
});
test("snappy-artifact-loop: 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",
"invalid_argument",
] as const satisfies readonly RefusalCode[];
test("snappy-artifact-loop: the refusal table declares exactly the codes this test names", () => {
assert.deepEqual(Object.keys(HAND_CONTRACT.refusals ?? {}).sort(), [...DECLARED].sort());
});
test("snappy-artifact-loop: 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`);
}
});