skills/resend/SKILL.md
Wire Resend transactional email + audiences into a TanStack Start + Cloudflare Workers app. Install, env config, server SDK, send-email route with React Email components, audiences for waitlist (resend.contacts.create), webhook signature verification via resend.webhooks.verify, sending-domain DKIM/SPF setup, broadcast send. Phase 0 unblocker for marketing landing → email capture → confirmation pattern. Use when user wants to add Resend, wire email, transactional email, waitlist, audience, React Email, send email from a Worker, email confirmation, Resend webhook, broadcast, or unsubscribe handling.
npx skillsauth add RonanCodes/ronan-skills resendInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Wire Resend into a TanStack Start + Drizzle + D1 app on Cloudflare Workers. Transactional email via resend.emails.send, React Email components for the body, audiences for waitlist capture, webhook signature verification via resend.webhooks.verify (built-in svix wrapper, no extra package needed).
This is the canonical email pick for the user's stack as of 2026-05-05. Phase 0 unblocker pattern: marketing landing → email capture into a Resend audience → confirmation email → graduate to full transactional once the product ships. Cloudflare Email Service is the Workers-native sibling but lacks deliverability track record (use it for internal-only notifications today; revisit for customer-facing in late 2026).
/ro:resend install # initial wiring (env + client + Drizzle event table)
/ro:resend add-send # /api/email/send route + React Email scaffold
/ro:resend add-waitlist # marketing-landing form + audience capture + confirmation
/ro:resend add-webhook # /api/webhooks/resend with signature verify + idempotency
/ro:resend add-domain # DKIM/SPF/return-path DNS records (cross-link /ro:cloudflare-dns)
/ro:resend add-broadcast # script to send a broadcast to an audience
/ro:new-tanstack-app or /ro:migrate-to-tanstack)src/db/schema.ts, wrangler.toml with [[d1_databases]])/ro:clerk default) if any email is keyed to authenticated users; not required for pure waitlist landing| Plan | Monthly | Emails/mo | Sending domains | Audiences | |---|---|---|---|---| | Free | $0 | 3,000 | 1 | yes | | Pro | $20 | 50,000 | 10 | yes | | Scale | $90+ | 100K+ | unlimited | yes |
For a Phase 0 waitlist landing, Free covers thousands of signups. The first paid trigger is usually domain count (one product per sending domain) rather than email volume.
pnpm add resend # server SDK, fetch-based, Workers-compatible
pnpm add -D react-email @react-email/components # email components, dev-only since they render at build/request time
resend v6+ uses Web fetch under the hood. No httpClient override needed for Workers; the SDK auto-detects the runtime. Webhook verification is built in (resend.webhooks.verify), so you do NOT need to install svix separately.
Which Resend account? Default personal, never Simplicity for a personal app. There are TWO Resend accounts in
~/.claude/.env: the bareRESEND_API_KEYis the Simplicity Labs account (sends fromsimplicitylabs.io), andRESEND_API_KEY_RONANis the personal Ronan Connolly account (sends fromronanconnolly.dev). Before copying a key,grep -i resend "$(ro context env)"to see both, then pick by the app:
- Personal app (anything on
ronanconnolly.dev, a 12-in-12 side project, a hackathon build) → useRESEND_API_KEY_RONANand afrom:onronanconnolly.dev. Never the bare key, never asimplicitylabs.iosender.- Simplicity / Dataforce / Taskforce app → use the bare
RESEND_API_KEYand asimplicitylabs.io(or product-domain) sender.- Ambiguous? Ask the user which account; do not guess.
The per-app
RESEND_API_KEYsecret below holds whichever account's live key the app should send from — the variable name staysRESEND_API_KEYper-app; what matters is which account's key you copied into it. Full rule + the other multi-account services (Cloudflare etc.):/ro:env§ "Multiple accounts for one service".
# .dev.vars
RESEND_API_KEY=re_test_...
RESEND_WEBHOOK_SECRET=whsec_... # only after add-webhook
RESEND_AUDIENCE_ID= # only after add-waitlist (the audience ID from dashboard)
Production secrets:
wrangler secret put RESEND_API_KEY
wrangler secret put RESEND_WEBHOOK_SECRET # only after add-webhook
wrangler secret put RESEND_AUDIENCE_ID # only after add-waitlist
Test API keys (re_test_...) only deliver to verified test addresses (and [email protected], [email protected], [email protected]). Live keys (re_live_...) deliver to anyone but require a verified sending domain.
src/lib/resend.tsimport { Resend } from 'resend';
let cached: { key: string; client: Resend } | null = null;
export function getResend(env: Env): Resend {
if (cached?.key === env.RESEND_API_KEY) return cached.client;
const client = new Resend(env.RESEND_API_KEY);
cached = { key: env.RESEND_API_KEY, client };
return client;
}
Add RESEND_API_KEY, RESEND_WEBHOOK_SECRET, RESEND_AUDIENCE_ID to your Env shape (worker-configuration.d.ts, regenerated by wrangler types).
src/db/schema.tsOnly the events table is load-bearing. The waitlist itself lives in Resend (audiences own the canonical list).
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
export const emailEvents = sqliteTable('email_events', {
id: text('id').primaryKey(), // svix-id, used for idempotency
type: text('type').notNull(), // email.sent | email.delivered | email.bounced | email.complained | email.opened | email.clicked
emailId: text('email_id'), // Resend's email id (em_...)
to: text('to'),
receivedAt: integer('received_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
payload: text('payload'), // raw JSON, for replay/debug
});
Run pnpm drizzle-kit generate then pnpm drizzle-kit migrate (or your repo's migration script).
Send a single transactional email from a server route. React Email component for the body.
src/emails/welcome.tsximport { Body, Container, Head, Heading, Html, Link, Text } from '@react-email/components';
export function WelcomeEmail({ name, ctaUrl }: { name: string; ctaUrl: string }) {
return (
<Html>
<Head />
<Body style={{ fontFamily: 'system-ui, sans-serif', padding: '24px' }}>
<Container>
<Heading>Welcome, {name}.</Heading>
<Text>Thanks for signing up. Confirm your email to get started.</Text>
<Link href={ctaUrl} style={{ background: '#000', color: '#fff', padding: '12px 20px', textDecoration: 'none' }}>
Confirm email
</Link>
</Container>
</Body>
</Html>
);
}
src/routes/api/email/send.tsimport { createServerFileRoute } from '@tanstack/react-start/server';
import { getResend } from '@/lib/resend';
import { WelcomeEmail } from '@/emails/welcome';
export const ServerRoute = createServerFileRoute('/api/email/send').methods({
POST: async ({ request, context }) => {
const env = context.cloudflare.env;
const { email, name, ctaUrl } = (await request.json()) as { email: string; name: string; ctaUrl: string };
const resend = getResend(env);
const { data, error } = await resend.emails.send({
from: 'Acme <[email protected]>', // must be a verified sending domain (see add-domain)
to: [email],
subject: 'Welcome to Acme',
react: WelcomeEmail({ name, ctaUrl }), // call as a function, not JSX
});
if (error) return Response.json({ error }, { status: 500 });
return Response.json({ id: data?.id });
},
});
react: WelcomeEmail({ ... }) is the call-as-function pattern Resend documents. Do NOT pass <WelcomeEmail name={...} /> JSX; the SDK expects the React element returned by the function call.
The Phase 0 unblocker pattern. Marketing landing page captures emails into a Resend audience, fires a confirmation email, graduates to product onboarding when ready.
// scripts/create-audience.ts (run once with pnpm tsx)
import 'dotenv/config';
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY!);
const audience = await resend.audiences.create({ name: 'Dataforce Waitlist' });
console.log('Audience ID:', audience.data?.id); // paste this as RESEND_AUDIENCE_ID
Or click through Resend → Audiences → New audience.
Audiences vs Segments note (verify on resend.com/docs): Resend's docs in 2026 mark "Audiences" as deprecated in favor of "Segments" for new builds. The
resend.audiences.*SDK methods still work and the dashboard still surfaces them. If you are starting fresh, check whetherresend.segments.*is in the SDK version you installed and prefer it; the shape is similar. The signups examples below useaudiencesbecause that is what is documented end-to-end today.
src/routes/api/waitlist/signup.tsimport { createServerFileRoute } from '@tanstack/react-start/server';
import { getResend } from '@/lib/resend';
import { WelcomeEmail } from '@/emails/welcome';
export const ServerRoute = createServerFileRoute('/api/waitlist/signup').methods({
POST: async ({ request, context }) => {
const env = context.cloudflare.env;
const { email, firstName } = (await request.json()) as { email: string; firstName?: string };
if (!email || !email.includes('@')) {
return Response.json({ error: 'Invalid email' }, { status: 400 });
}
const resend = getResend(env);
// Add to audience
const contact = await resend.contacts.create({
email,
firstName,
unsubscribed: false,
audienceId: env.RESEND_AUDIENCE_ID,
});
// Fire confirmation email
await resend.emails.send({
from: 'Acme <[email protected]>',
to: [email],
subject: 'You are on the list',
react: WelcomeEmail({ name: firstName ?? 'there', ctaUrl: 'https://your-domain.com' }),
});
return Response.json({ ok: true, contactId: contact.data?.id });
},
});
src/routes/index.tsximport { useState } from 'react';
export function WaitlistForm() {
const [status, setStatus] = useState<'idle' | 'submitting' | 'done' | 'error'>('idle');
return (
<form
onSubmit={async (e) => {
e.preventDefault();
setStatus('submitting');
const fd = new FormData(e.currentTarget);
const res = await fetch('/api/waitlist/signup', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: fd.get('email'), firstName: fd.get('firstName') }),
});
setStatus(res.ok ? 'done' : 'error');
}}
>
<input name="firstName" placeholder="First name" />
<input name="email" type="email" required placeholder="[email protected]" />
<button type="submit" disabled={status === 'submitting'}>Join the waitlist</button>
{status === 'done' && <p>Check your inbox.</p>}
</form>
);
}
The simplest route: send a "click to confirm" email with a one-time token, only mark the contact as subscribed after the click. The unsubscribed flag on contacts.create toggles their inclusion in broadcasts, so create with unsubscribed: true then update via resend.contacts.update({ id, audienceId, unsubscribed: false }) after they click.
For a Phase 0 launch, single opt-in is usually fine and matches typical SaaS waitlist UX. Move to double opt-in if you start seeing spam-trap signups.
Resend auto-injects a {{{RESEND_UNSUBSCRIBE_URL}}} token in every broadcast. For one-off transactional emails you must include your own. The simplest pattern is a route at /api/email/unsubscribe?id=<contact-id> that calls resend.contacts.update({ id, audienceId, unsubscribed: true }).
Resend pushes events on send, delivery, bounce, complaint, open, click. Sync to the email_events Drizzle table for idempotency, then react in your domain logic (mark a contact as bounced, alert on a complaint, etc.).
src/routes/api/webhooks/resend.tsimport { createServerFileRoute } from '@tanstack/react-start/server';
import { getResend } from '@/lib/resend';
import { db } from '@/db';
import { emailEvents } from '@/db/schema';
import { eq } from 'drizzle-orm';
export const ServerRoute = createServerFileRoute('/api/webhooks/resend').methods({
POST: async ({ request, context }) => {
const env = context.cloudflare.env;
const resend = getResend(env);
const id = request.headers.get('svix-id');
const timestamp = request.headers.get('svix-timestamp');
const signature = request.headers.get('svix-signature');
if (!id || !timestamp || !signature) return new Response('Missing headers', { status: 400 });
const payload = await request.text();
let event: { type: string; data: any };
try {
event = resend.webhooks.verify({
payload,
headers: { id, timestamp, signature },
webhookSecret: env.RESEND_WEBHOOK_SECRET,
}) as { type: string; data: any };
} catch {
return new Response('Bad signature', { status: 400 });
}
// Idempotency: skip if we have already processed this svix-id
const seen = await db.select().from(emailEvents).where(eq(emailEvents.id, id)).get();
if (seen) return new Response('ok (replay)');
await db.insert(emailEvents).values({
id,
type: event.type,
emailId: event.data?.email_id ?? null,
to: Array.isArray(event.data?.to) ? event.data.to[0] : event.data?.to ?? null,
payload,
});
switch (event.type) {
case 'email.bounced':
case 'email.complained':
// Mark the contact as do-not-send. Optional: pull from audience.
break;
case 'email.delivered':
case 'email.opened':
case 'email.clicked':
// Engagement signal, do whatever your product does with it.
break;
}
return new Response('ok');
},
});
In the Resend dashboard, navigate to Webhooks, create an endpoint pointing at https://your-app.com/api/webhooks/resend, copy the signing secret, push it as RESEND_WEBHOOK_SECRET (per-app, NOT global).
resend.webhooks.verify is a thin wrapper over svix; if a future SDK release renames it, you can swap to import { Webhook } from 'svix' directly with the same headers.
Production sending requires a verified domain. Resend gives you the DKIM, SPF, and (optional) DMARC records to add to your DNS.
your-domain.com. Resend shows three or four records (CNAME for DKIM, TXT for SPF, MX for return-path)./ro:cloudflare-dns add-cname --name resend._domainkey --value <resend-value>
/ro:cloudflare-dns add-txt --name @ --value "v=spf1 include:amazonses.com ~all"
See /ro:cloudflare-dns for the full call shape.from address in your send routes from [email protected] (Resend's sandbox) to [email protected].Add a TXT at _dmarc.your-domain.com:
v=DMARC1; p=quarantine; pct=10; rua=mailto:[email protected]
Start at p=quarantine; pct=10, watch the aggregate reports for a couple of weeks, then ramp pct up and p to reject once you are confident.
Send a one-off broadcast to everyone in an audience (release announcements, beta invites, weekly newsletter).
// scripts/send-broadcast.ts
import 'dotenv/config';
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY!);
const broadcast = await resend.broadcasts.create({
audienceId: process.env.RESEND_AUDIENCE_ID!,
from: 'Acme <[email protected]>',
subject: 'We launched',
html: '<p>Dataforce is live. <a href="https://your-domain.com">Sign in</a>.</p>',
});
console.log('Broadcast created:', broadcast.data?.id);
// Send it
const sent = await resend.broadcasts.send(broadcast.data!.id);
console.log('Broadcast sent:', sent);
Broadcasts auto-include the unsubscribe link ({{{RESEND_UNSUBSCRIBE_URL}}} token), so contacts who opt out via this route get pulled from the audience automatically.
Test mode delivers only to verified addresses + the three Resend test inboxes:
| Address | Triggers |
|---|---|
| [email protected] | Normal delivery |
| [email protected] | Bounced webhook event |
| [email protected] | Complaint webhook event |
For webhook signature testing locally, use the Resend CLI's webhook forwarder (similar to stripe listen), or post a test event from the dashboard. The Resend dashboard "Send test webhook" button fires a real signed event at your endpoint.
| Case | Pick instead | Why | |---|---|---| | Internal-only notifications, want everything in Cloudflare | Cloudflare Email Service | Workers binding, no extra account. Lacks deliverability track record for customer-facing today | | Mailing-list scale (hundreds of thousands of monthly broadcasts) | Postmark or AWS SES | Cheaper at high volume, established deliverability | | Marketing automation flows (drip campaigns, segments by behaviour) | Loops or Customer.io | Resend has audiences but is transactional-first |
| Var | Where | Source |
|---|---|---|
| RESEND_API_KEY | .dev.vars + wrangler secret | Resend dashboard → API Keys (separate test + live) |
| RESEND_WEBHOOK_SECRET | wrangler secret (after add-webhook) | Resend dashboard → Webhooks → endpoint detail (one per endpoint) |
| RESEND_AUDIENCE_ID | wrangler secret (after add-waitlist) | Resend dashboard → Audiences → audience detail |
ronanconnolly.dev), never simplicitylabs.io, for a personal app. The from: brand on a personal app (side project, hackathon build, anything on ronanconnolly.dev) is Ronan Connolly. Only send from a simplicitylabs.io (or product-domain) address when the app is explicitly a Simplicity Labs / Dataforce / Taskforce venture. Pair the matching account key (see § "Per-app env vars" above): personal sender → RESEND_API_KEY_RONAN; Simplicity sender → bare RESEND_API_KEY. Mismatching them (e.g. a Simplicity key with a ronanconnolly.dev sender) fails the domain-verification check.re_live_... key in ~/.claude/.env. Per-app secrets only. A leaked live key lets anyone send email from your verified domain (deliverability + reputation risk).resend.webhooks.verify before trusting payload data. Without this, anyone can post fake email.bounced events and unsubscribe contacts.email_events table. Resend retries on 5xx for several days; the same event can land twice.mail.your-domain.com for marketing and keep your-domain.com for transactional only, to protect deliverability if a marketing campaign gets flagged.resend.contacts.remove covers the contact, you handle the consent log yourself./ro:clerk for auth (must be wired before user-keyed transactional email)/ro:cloudflare-dns for DKIM / SPF / DMARC records/ro:new-tanstack-app to scaffold the app first/ro:cf-ship to ship after wiringtesting
--- name: linear-pipeline description: The Fable orchestrator for a single dispatched Linear ticket. Holds almost no context itself; it receives `--issue <ID> --detached`, decides the stage sequence, and fans out a sub-agent per stage, passing forward only each stage's artifact (never re-derived, never inlined into its own context). Step zero, before any planning or stage routing, is a boundary triage against `canon/security-boundary.md` (#199): a match tags Ronan Connolly and stops the run, no
development
--- name: in-your-face description: Capture a chat-only answer into a durable artifact (markdown + HTML, PDF when cheap) and launch it automatically so the user cannot miss it. Use when user says "in your face", "don't let me lose this", "save that answer", "make that durable", or right after answering a substantive side question (a recipe, comparison, how-to, or generated prompt) that would otherwise die with the context. category: workflow argument-hint: [--no-open] [--vault <short>] [hint of
tools
One-shot headless OpenAI Codex CLI calls for background/admin AI tasks — summaries, classification, extraction, admin glue. The default engine for anything that runs AI constantly in the background (daemon-driven, per-event), because it bills the flat ChatGPT subscription instead of Claude usage or per-token API spend, and it keeps working while Claude is rate-limited. NEVER for coding — coding stays Claude. Use when a skill or daemon needs a cheap always-on AI call, when the user says "use codex", "ask codex", "codex as backup", or when building a background summarizer/classifier into a listener or loop. Reads auth from ~/.codex/auth.json (ChatGPT account, no API key).
research
Turn a warranty rejection, repair quote, or RMA email into a cited decision brief — legal read (NL/EU consumer law), is the part user-serviceable, live part and new-unit prices, repair-vs-DIY-vs-new economics, before-you-send-it checklist, deadlines. Use when the user pastes or screenshots a repair quote, warranty rejection, "not covered" email, onderzoekskosten fee, or asks "should I repair or replace this".