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.
# .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 |
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 wiringdevelopment
Close the loop on a Linear ticket when its work ships - move the status and post a deploy comment with the PR link, what shipped, and a try-it link, mentioning the collaborator. Used as the tail of /ro:linear-nightshift for every merged mirror, or manually after an ad-hoc build. Triggers on "linear update", "update the linear ticket", "mark NUT-x done", "tell eoin it shipped", "/ro:linear-update".
devops
Run a night-shift against a collaborator's Linear board. Pulls the team's Grilled tickets (/ro:linear-grill moves a ticket to Grilled once its questions are answered), VERIFIES the questions were actually answered (unanswered → bounce the ticket to the "Question for <name>" state), mirrors verified tickets to ephemeral GitHub issues with ready-for-agent, then runs the standard /ro:night-shift machinery on GitHub. Tail-calls /ro:linear-update for everything that merged + deployed. Triggers on "linear nightshift", "nightshift linear", "drain the linear board", "run the shift off linear", "/ro:linear-nightshift".
development
Grill a collaborator's Linear tickets and move every processed ticket to where it belongs. Resolves the board from the repo's .ro-linear.json, reads the collaborator's Backlog / Ready-for-agent issues, then per ticket either posts 3-5 decision-extracting questions (state moves to "Question for <name>") or confirms it build-ready (state moves to "Grilled", the gate /ro:linear-nightshift consumes); shipped-and-confirmed tickets close as Done. The async-collaborator counterpart of /ro:day-shift for people who never touch GitHub. Triggers on "grill linear", "grill eoin's tickets", "linear grill", "add questions to the linear tickets", "/ro:linear-grill".
development
--- name: about-page description: Add a standard About page to any web app, what it is, the tech stack, and an FAQ, wired into a footer link with a sticky footer. Built with Spartan + Tailwind (the canonical component layer) and falls back to semantic HTML so it ships reliably. Use whenever building, polishing, or shipping an app, every app should have one. Triggers on "add an about page", "about page", "footer about link", or as a standard step in app build/polish. category: frontend argument-h