Nostr Waitlist Skill
Scaffolds a polished email + Nostr "early access" flow into the host Next.js
project. The output is opinionated about UX (Radix dialog, three-state form
based on email vs npub vs NIP-05, confetti on success) and unopinionated
about destinations — the user picks any combination of bundled adapters
(Resend Audiences, webhook, Postgres, Supabase, SQLite) or writes their own
satisfying the small Destination interface.
References
references/nostr-keys.md— how to generate annsec, what to put in envreferences/destination-adapters.md— theDestinationinterface + a worked example of a custom adapterreferences/db-schema.sql— canonicalwaitlisttable for Postgres + SQLitereferences/nip-cheatsheet.md— quick recap of NIP-04 / NIP-05 / NIP-44
Phase 1 — Detect stack
Before asking the user anything, gather facts about the host project.
# Run from the project root the user is currently in
cat package.json | grep -E '"(next|react)"' || echo "NOT_NEXT"
ls app/ 2>/dev/null && echo "HAS_APP"
ls pages/api/ 2>/dev/null && echo "HAS_PAGES_API"
ls tsconfig.json 2>/dev/null && echo "HAS_TS"
ls tailwind.config.ts tailwind.config.js tailwind.config.mjs 2>/dev/null && echo "HAS_TAILWIND"
ls pnpm-lock.yaml yarn.lock package-lock.json bun.lockb 2>/dev/null
ls components/ui/dialog.tsx components/ui/button.tsx components/ui/input.tsx 2>/dev/null
Decide:
- NOT_NEXT → stop. Tell the user "this skill targets Next.js. Detected: ." and exit.
- HAS_APP present → use App Router templates (
templates/api/app-router/). - Only HAS_PAGES_API → use Pages Router templates.
- Both present → ask the user which to target (Modal vs section can also live in either; route handler placement is what matters).
- Package manager: pnpm-lock →
pnpm, yarn.lock →yarn, package-lock.json →npm, bun.lockb →bun. Defaultnpmif none found. - shadcn primitives detected → set
EMIT_UI = false. OtherwiseEMIT_UI = true.
Phase 2 — Collect scope
Use AskUserQuestion. Batch related questions into a single prompt with ≤4 items each.
Prompt 1 — channels and provider:
- Which channels? (single) —
email/nostr/both(defaultboth) - Email provider? (single, only if email enabled) —
resend/skip
Prompt 2 — bake-once values:
- Brand name (free text) — used in email subject, modal copy, DM
- Sender display + address (free text, only if email enabled) — e.g.
Acme <[email protected]>. Baked intolib/resend.ts. - Site URL (free text, only if email enabled) — e.g.
https://acme.com. Baked into the welcome email template. - D-tag (free text, only if nostr enabled) — defaults to
<project-slug>-waitlist.
Prompt 3 — destination adapters (multi-select):
none / resend-audience / webhook / postgres / supabase / sqlite
For every adapter chosen, emit its file but leave it commented out in
lib/waitlist/destinations.ts. The user uncomments + supplies env when
they're ready.
Prompt 4 — UI surface:
modal (default) / inline-section / both
Phase 3 — Install deps
Pick the right package manager command (pnpm add, npm install, yarn add,
bun add) and run one install with all packages — running multiple
installs back to back is slower and noisier.
Always:
nostr-tools ws @noble/hashes canvas-confetti lucide-react
If EMIT_UI = true:
@radix-ui/react-dialog @radix-ui/react-slot class-variance-authority clsx tailwind-merge
Channel-conditional:
- Email enabled:
resend
Adapter-conditional:
postgresadapter selected:postgressupabaseadapter selected:@supabase/supabase-jssqliteadapter selected:better-sqlite3
Dev deps in one extra call (only if TypeScript):
@types/canvas-confetti @types/ws
# plus @types/better-sqlite3 if sqlite adapter
Phase 4 — Emit files
For every template file in templates/, read it, replace tokens, and write
to the corresponding path in the host project.
Token map
Built once from Phase 1 + Phase 2 answers:
| Token | Source | Example |
|---|---|---|
{{BRAND_NAME}} | Prompt 2 | Acme |
{{SENDER_NAME}} | Prompt 2 (parsed from Sender display) | Acme |
{{SENDER_EMAIL}} | Prompt 2 (parsed from Sender display) | [email protected] |
{{SITE_URL}} | Prompt 2 | https://acme.com |
{{LOGO_URL}} | derived from {{SITE_URL}}/logo.svg (or asked) | https://acme.com/logo.svg |
{{WAITLIST_D_TAG}} | Prompt 2 | acme-waitlist |
{{WELCOME_SUBJECT}} | derived: Welcome to the {{BRAND_NAME}} waitlist! | |
{{WELCOME_DM_FIRSTLINE}} | derived: Hey — thanks for jumping on the {{BRAND_NAME}} waitlist over Nostr. | |
{{BRAND_PRIMARY}} / {{BRAND_ACCENT}} / {{BRAND_SUCCESS}} / {{BG}} | Phase 2 (or sane defaults) | #F5A623 etc. |
The literal sentinel __YEAR__ (note: underscores, not braces) is not a
scaffold token — it stays in the rendered email template and is substituted
at runtime by lib/resend.ts so the copyright year stays fresh year over
year. Don't add it to your token map.
For component CSS variables, prefer arbitrary value classes
(bg-[var(--brand-primary)]) backed by CSS custom properties the skill
inserts into the user's globals.css (offer to insert; don't overwrite).
Output paths (App Router)
lib/nostr.ts ← templates/lib/nostr.ts.tpl
lib/resend.ts ← templates/lib/resend.ts.tpl (only if email)
lib/waitlist/types.ts ← templates/lib/waitlist/types.ts.tpl
lib/waitlist/destinations.ts ← templates/lib/waitlist/destinations.ts.tpl
lib/waitlist/run.ts ← templates/lib/waitlist/run.ts.tpl
lib/waitlist/adapters/<name>.ts ← templates/lib/waitlist/adapters/<name>.ts.tpl
(one per selected adapter)
app/api/waitlist/check/route.ts ← templates/api/app-router/check.route.ts.tpl
app/api/waitlist/subscribe/route.ts ← templates/api/app-router/subscribe.route.ts.tpl
components/waitlist/waitlist-modal.tsx ← templates/components/waitlist-modal.tsx.tpl (if modal)
components/waitlist/waitlist-section.tsx ← templates/components/waitlist-section.tsx.tpl
(if inline-section)
components/waitlist/waitlist-trigger.tsx ← templates/components/waitlist-trigger.tsx.tpl
components/ui/{dialog,button,input}.tsx ← templates/components/ui/*.tsx.tpl (only if EMIT_UI)
templates/waitlist-welcome.html ← templates/email/waitlist-welcome.html.tpl (only if email)
templates/nostr-welcome.txt ← templates/nostr/nostr-welcome.txt.tpl (only if nostr)
Output paths (Pages Router)
Same as above, except API routes go to:
pages/api/waitlist/check.ts ← templates/api/pages-router/check.api.ts.tpl
pages/api/waitlist/subscribe.ts ← templates/api/pages-router/subscribe.api.ts.tpl
Append-only .env.example
Read existing .env.example if any. Append (don't overwrite) the keys
relevant to the user's choices:
| Key | Channel/Adapter | Notes |
|---|---|---|
PRIVATE_KEY | nostr | nsec1… or 64-char hex; instructions in references/nostr-keys.md |
RELAY_URLS | nostr (optional) | comma-sep override; bundled defaults work |
RESEND_API_KEY | https://resend.com/api-keys | |
RESEND_AUDIENCE_ID | resend-audience adapter | https://resend.com/audiences |
WEBHOOK_URL | webhook adapter | full https URL |
WEBHOOK_SECRET | webhook adapter (optional) | HMAC key, hex |
DATABASE_URL | postgres adapter | postgres://user:pass@host/db |
SUPABASE_URL + SUPABASE_SERVICE_ROLE | supabase adapter | service-role only — never expose client-side |
SQLITE_PATH | sqlite adapter | path to .db file |
Each key gets a one-line # comment above it explaining where to get it.
Phase 5 — Wire trigger
If modal was chosen, point the user at where to mount <WaitlistTrigger />
or <WaitlistModal />. Don't auto-edit pages — show a 5-line snippet they
can paste into their hero/header. Suggest the obvious spot (header/hero CTA)
based on a quick grep for "early access", "get started", "join", or
similar copy in app/page.tsx or pages/index.tsx.
If inline-section was chosen, append (don't overwrite) a <WaitlistSection />
import + render to the most likely landing file, after asking
"insert into app/page.tsx after line N? (y/n)".
Phase 6 — Smoke test
Print:
- Env-var checklist with where-to-get-it links per key emitted in Phase 4.
- Optional dev-server check:
- "want me to start the dev server and curl
/api/waitlist/check? (y/n)" - if yes:
pnpm devbackground, wait 4s,curl -X POST localhost:3000/api/waitlist/check -H 'Content-Type: application/json' -d '{"contact":"[email protected]"}' - expect
{ "hasNip05": false }(a real NIP-05 like[email protected]would return{ "hasNip05": true })
- "want me to start the dev server and curl
- Do not auto-call
/api/waitlist/subscribe— that triggers real Resend + relay traffic. Tell the user how to test it themselves with their own email.
End with a one-paragraph "what's next": fill .env, mount the trigger,
run pnpm tsc --noEmit, deploy.
Notes for the agent running this skill
- Don't overwrite files without diffing. If
lib/nostr.tsalready exists, ask first. - Append, never replace in
.env.example,globals.css, and any page the user already authored. - If the user picks
email-only, skip every Nostr file — includinglib/nostr.ts, the NIP-04 DM template, the npub/nip05 detection branches in the modal. Ship a slimmer modal with a single email field. (Source:templates/components/waitlist-modal.tsx.tplhas a{{NOSTR_BRANCHES}}block that gets replaced with empty string when Nostr is disabled.) - The
Destinationadapters are best-effort. Errors logged, never thrown to the response. The user can change this inlib/waitlist/run.tsif they want hard failures.