| name | nostr-waitlist |
| description | Scaffold a complete email + Nostr-aware waitlist subscription flow into a Next.js project. Generates a Radix-based modal, /api/waitlist routes, NIP-04 encrypted DMs, NIP-44 encrypted waitlist event storage, optional Resend welcome email, and a pluggable destinations layer (Resend Audiences, webhook, Postgres, Supabase, SQLite, or custom). Detects App Router vs Pages Router and emits accordingly. Use when the user says "scaffold waitlist", "add nostr signup", "create subscribe form", "email + nostr waitlist", "early access form", or types `/nostr-waitlist`.
|
| allowed-tools | ["Read","Write","Edit","Bash","Glob","Grep","AskUserQuestion"] |
| metadata | {"author":"agustinkassis","version":"0.1.0"} |
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 an nsec, what to put in env
references/destination-adapters.md — the Destination interface + a
worked example of a custom adapter
references/db-schema.sql — canonical waitlist table for Postgres + SQLite
references/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.
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. Default npm if none found.
- shadcn primitives detected → set
EMIT_UI = false. Otherwise EMIT_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 (default both)
- 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 <hello@acme.com>. Baked into lib/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:
Adapter-conditional:
postgres adapter selected: postgres
supabase adapter selected: @supabase/supabase-js
sqlite adapter 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) | hello@acme.com |
{{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 | email | 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 dev background, wait 4s, curl -X POST localhost:3000/api/waitlist/check -H 'Content-Type: application/json' -d '{"contact":"test@example.com"}'
- expect
{ "hasNip05": false } (a real NIP-05 like agus@nostrplebs.com
would return { "hasNip05": true })
- 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.ts already
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 — including
lib/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.tpl has a
{{NOSTR_BRANCHES}} block that gets replaced with empty string when
Nostr is disabled.)
- The
Destination adapters are best-effort. Errors logged, never
thrown to the response. The user can change this in lib/waitlist/run.ts
if they want hard failures.