Load PROACTIVELY when task involves connecting external services or third-party APIs. Use when user says "add email sending", "integrate a CMS", "set up file uploads", "add analytics", or "connect to S3". Covers email services (Resend, SendGrid), CMS platforms (Sanity, Contentful, Payload), file upload solutions (UploadThing, Cloudinary, S3), analytics integration, webhook handling, error recovery, and credential management.
Implements and configures third-party service integrations like email, CMS, file upload, and analytics with proper error handling.
/plugin marketplace add mgd34msu/goodvibes-plugin/plugin install goodvibes@goodvibes-marketThis skill inherits all available tools. When active, it can use any tool Claude has access to.
references/service-patterns.mdscripts/validate-services.shscripts/
validate-services.sh
references/
service-patterns.md
This skill guides you through integrating external services into applications, from service selection to production deployment. It leverages GoodVibes precision tools for type-safe, resilient service integrations with proper error handling and testing.
Use this skill when you need to:
Required context:
Tools:
Before adding new services, analyze existing integrations to maintain consistency.
Use discover to analyze multiple aspects in parallel:
discover:
queries:
- id: email-sdks
type: grep
pattern: "(resend|sendgrid|postmark|nodemailer)"
glob: "package.json"
- id: cms-sdks
type: grep
pattern: "(@sanity|contentful|@payloadcms|@strapi)"
glob: "package.json"
- id: upload-sdks
type: grep
pattern: "(uploadthing|cloudinary|@aws-sdk/client-s3)"
glob: "package.json"
- id: analytics-sdks
type: grep
pattern: "(posthog-js|plausible|@vercel/analytics)"
glob: "package.json"
- id: api-keys-env
type: grep
pattern: "(RESEND_|SENDGRID_|SANITY_|CONTENTFUL_|UPLOADTHING_|CLOUDINARY_|AWS_|POSTHOG_)"
glob: ".env.example"
- id: service-clients
type: glob
patterns: ["src/lib/*client.ts", "src/services/**/*.ts", "lib/services/**/*.ts"]
- id: webhook-routes
type: glob
patterns: ["src/app/api/webhooks/**/*.ts", "pages/api/webhooks/**/*.ts"]
verbosity: files_only
Read existing service clients to understand the project's patterns:
precision_read:
files:
- path: "src/lib/email-client.ts"
extract: outline
- path: "src/services/upload.ts"
extract: symbols
output:
format: minimal
Decision Point: Use existing patterns for new integrations. If no patterns exist, follow the implementation guide below.
Choose services based on requirements, scale, and budget.
For transactional emails:
For marketing emails:
Recommendation: Start with Resend for transactional, migrate to SES at 1M+ emails/month.
For structured content (blog, docs):
For app content (products, user-generated):
For marketing pages:
Recommendation: Sanity for content-heavy sites, Payload for app backends.
For images (profile pics, product photos):
For large files (videos, documents):
For user-facing uploads with virus scanning:
Recommendation: UploadThing for prototypes, S3/R2 for production scale.
For product analytics:
For web analytics:
Recommendation: PostHog for product apps, Plausible for marketing sites.
precision_exec:
commands:
- cmd: "npm install resend"
expect:
exit_code: 0
verbosity: minimal
Write a type-safe email client with error handling:
precision_write:
files:
- path: "src/lib/email.ts"
mode: fail_if_exists
content: |
import type { ReactElement } from 'react';
import { Resend } from 'resend';
if (!process.env.RESEND_API_KEY) {
throw new Error('RESEND_API_KEY is required');
}
export const resend = new Resend(process.env.RESEND_API_KEY);
export interface SendEmailOptions {
to: string | string[];
subject: string;
html?: string;
react?: ReactElement;
from?: string;
}
export async function sendEmail(options: SendEmailOptions) {
const { to, subject, html, react, from = 'noreply@yourapp.com' } = options;
try {
const { data, error } = await resend.emails.send({
from,
to,
subject,
html,
react,
});
if (error) {
console.error('[Email] Send failed:', error);
throw new Error(`Email send failed: ${error.message}`);
}
console.log('[Email] Sent successfully:', data?.id);
return { success: true, id: data?.id };
} catch (error: unknown) {
console.error('[Email] Unexpected error:', error);
throw error;
}
}
verbosity: minimal
For transactional emails, use React Email for type-safe templates:
precision_exec:
commands:
- cmd: "npm install react-email @react-email/components"
expect:
exit_code: 0
verbosity: minimal
precision_write:
files:
- path: "emails/welcome.tsx"
mode: fail_if_exists
content: |
import {
Body,
Button,
Container,
Head,
Heading,
Html,
Preview,
Text,
} from '@react-email/components';
interface WelcomeEmailProps {
userName: string;
loginUrl: string;
}
export default function WelcomeEmail({ userName, loginUrl }: WelcomeEmailProps) {
return (
<Html>
<Head />
<Preview>Welcome to our platform!</Preview>
<Body style={main}>
<Container style={container}>
<Heading style={h1}>Welcome, {userName}!</Heading>
<Text style={text}>
We're excited to have you on board. Click the button below to get started.
</Text>
<Button href={loginUrl} style={button}>
Get Started
</Button>
</Container>
</Body>
</Html>
);
}
const main = { backgroundColor: '#f6f9fc', fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif' };
const container = { margin: '0 auto', padding: '40px 20px' };
const h1 = { color: '#1f2937', fontSize: '24px', fontWeight: 'bold', marginBottom: '20px' };
const text = { color: '#4b5563', fontSize: '16px', lineHeight: '24px', marginBottom: '20px' };
const button = { backgroundColor: '#3b82f6', borderRadius: '6px', color: '#ffffff', display: 'inline-block', fontSize: '16px', fontWeight: '600', padding: '12px 24px', textDecoration: 'none' };
verbosity: minimal
precision_write:
files:
- path: ".env.example"
mode: overwrite
content: |
# Email (Resend)
RESEND_API_KEY=your_resend_api_key_here
verbosity: minimal
precision_exec:
commands:
- cmd: "npm install @sanity/client @sanity/image-url"
expect:
exit_code: 0
verbosity: minimal
precision_write:
files:
- path: "src/lib/sanity.ts"
mode: fail_if_exists
content: |
import { createClient } from '@sanity/client';
import imageUrlBuilder from '@sanity/image-url';
import type { SanityImageSource } from '@sanity/image-url/lib/types/types';
if (!process.env.NEXT_PUBLIC_SANITY_PROJECT_ID) {
throw new Error('NEXT_PUBLIC_SANITY_PROJECT_ID is required');
}
if (!process.env.NEXT_PUBLIC_SANITY_DATASET) {
throw new Error('NEXT_PUBLIC_SANITY_DATASET is required');
}
export const sanityClient = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET,
apiVersion: '2026-01-01', // Update to current API version date
useCdn: process.env.NODE_ENV === 'production',
});
const builder = imageUrlBuilder(sanityClient);
// Import from @sanity/image-url
export function urlForImage(source: SanityImageSource) {
return builder.image(source).auto('format').fit('max');
}
// Type-safe query helper
export async function sanityFetch<T>(query: string, params?: Record<string, unknown>): Promise<T> {
try {
const result = await sanityClient.fetch<T>(query, params);
return result;
} catch (error: unknown) {
console.error('[Sanity] Query failed:', error);
throw new Error('Failed to fetch from Sanity');
}
}
verbosity: minimal
For real-time content updates, implement a webhook handler:
precision_write:
files:
- path: "src/app/api/webhooks/sanity/route.ts"
mode: fail_if_exists
content: |
import { NextRequest, NextResponse } from 'next/server';
import { revalidateTag } from 'next/cache';
import { timingSafeEqual } from 'crypto';
export async function POST(request: NextRequest) {
const signature = request.headers.get('sanity-webhook-signature');
// Verify webhook signature
const secret = process.env.SANITY_WEBHOOK_SECRET;
if (!secret) {
return NextResponse.json({ error: 'Webhook secret not configured' }, { status: 500 });
}
const expected = Buffer.from(secret);
const received = Buffer.from(signature || '');
if (expected.length !== received.length || !timingSafeEqual(expected, received)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
try {
const body = await request.json();
const { _type } = body;
// Revalidate cache based on content type
if (_type === 'post') {
revalidateTag('posts');
} else if (_type === 'page') {
revalidateTag('pages');
}
console.log('[Webhook] Sanity content updated:', _type); // Note: Use structured logger in production
return NextResponse.json({ revalidated: true });
} catch (error: unknown) {
console.error('[Webhook] Failed to process Sanity webhook:', error);
return NextResponse.json({ error: 'Webhook processing failed' }, { status: 500 });
}
}
verbosity: minimal
precision_exec:
commands:
- cmd: "npm install uploadthing @uploadthing/react"
expect:
exit_code: 0
verbosity: minimal
precision_write:
files:
- path: "src/app/api/uploadthing/core.ts"
mode: fail_if_exists
content: |
import { createUploadthing, type FileRouter } from 'uploadthing/next';
const f = createUploadthing();
export const ourFileRouter = {
imageUploader: f({ image: { maxFileSize: '4MB', maxFileCount: 4 } })
.middleware(async ({ req }) => {
// Authenticate user (placeholder imports shown for context)
// In real code: import { getUserFromRequest } from '@/lib/auth';
const user = await getUserFromRequest(req); // Import from your auth module
if (!user) throw new Error('Unauthorized');
return { userId: user.id };
})
.onUploadComplete(async ({ metadata, file }) => {
console.log('[Upload] Complete:', file.url);
// Save to database (Assumes Prisma client or similar ORM)
await db.image.create({
data: {
url: file.url,
userId: metadata.userId,
},
});
return { url: file.url };
}),
pdfUploader: f({ pdf: { maxFileSize: '16MB' } })
.middleware(async ({ req }) => {
const user = await getUserFromRequest(req);
if (!user) throw new Error('Unauthorized');
return { userId: user.id };
})
.onUploadComplete(async ({ metadata, file }) => {
console.log('[Upload] PDF complete:', file.url);
return { url: file.url };
}),
} satisfies FileRouter;
export type OurFileRouter = typeof ourFileRouter;
verbosity: minimal
For more control, use S3 with presigned URLs:
precision_exec:
commands:
- cmd: "npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner"
expect:
exit_code: 0
verbosity: minimal
precision_write:
files:
- path: "src/lib/s3.ts"
mode: fail_if_exists
content: |
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
// Validate S3 configuration
if (!process.env.AWS_REGION || !process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY || !process.env.AWS_S3_BUCKET) {
throw new Error('Missing required AWS S3 environment variables');
}
const s3Client = new S3Client({
region: process.env.AWS_REGION,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
},
});
export async function getUploadUrl(key: string, contentType: string) {
const command = new PutObjectCommand({
Bucket: process.env.AWS_S3_BUCKET,
Key: key,
ContentType: contentType,
});
// URL expires in 5 minutes
const url = await getSignedUrl(s3Client, command, { expiresIn: 300 });
return url;
}
verbosity: minimal
precision_exec:
commands:
- cmd: "npm install posthog-js"
expect:
exit_code: 0
verbosity: minimal
precision_write:
files:
- path: "src/providers/analytics.tsx"
mode: fail_if_exists
content: |
'use client';
import { useEffect } from 'react';
import type { ReactNode } from 'react';
import posthog from 'posthog-js';
export function AnalyticsProvider({ children }: { children: ReactNode }) {
useEffect(() => {
if (process.env.NEXT_PUBLIC_POSTHOG_KEY) {
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://app.posthog.com',
loaded: (posthog) => {
if (process.env.NODE_ENV === 'development') {
posthog.opt_out_capturing(); // Disabled in dev to avoid polluting analytics (re-enable for testing with posthog.opt_in_capturing())
}
},
});
}
}, []);
return <>{children}</>;
}
// Helper for tracking events
export function trackEvent(eventName: string, properties?: Record<string, unknown>) {
if (typeof window !== 'undefined') {
posthog.capture(eventName, properties);
}
}
verbosity: minimal
For services with rate limits, implement retry logic:
precision_write:
files:
- path: "src/lib/retry.ts"
mode: fail_if_exists
content: |
export interface RetryOptions {
maxAttempts?: number;
initialDelayMs?: number;
maxDelayMs?: number;
backoffMultiplier?: number;
}
export async function withRetry<T>(
fn: () => Promise<T>,
options: RetryOptions = {}
): Promise<T> {
const {
maxAttempts = 3,
initialDelayMs = 1000,
maxDelayMs = 10000,
backoffMultiplier = 2,
} = options;
let lastError: Error = new Error('All retry attempts failed');
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error: unknown) {
lastError = error instanceof Error ? error : new Error(String(error));
if (attempt === maxAttempts) {
break;
}
const delayMs = Math.min(
initialDelayMs * Math.pow(backoffMultiplier, attempt - 1),
maxDelayMs
);
console.log(`[Retry] Attempt ${attempt}/${maxAttempts} failed, retrying in ${delayMs}ms`); // Note: Use structured logger in production
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
throw lastError;
}
verbosity: minimal
Prevent cascading failures with a circuit breaker:
precision_write:
files:
- path: "src/lib/circuit-breaker.ts"
mode: fail_if_exists
content: |
export class CircuitBreaker {
private failureCount = 0;
private lastFailureTime: number | null = null;
private state: 'closed' | 'open' | 'half-open' = 'closed';
constructor(
private readonly failureThreshold = 5,
private readonly resetTimeoutMs = 60000
) {}
async execute<T>(fn: () => Promise<T>): Promise<T> {
if (this.state === 'open') {
if (Date.now() - (this.lastFailureTime ?? 0) > this.resetTimeoutMs) {
this.state = 'half-open';
} else {
throw new Error('Circuit breaker is open');
}
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (error: unknown) {
this.onFailure();
throw error;
}
}
private onSuccess() {
this.failureCount = 0;
this.state = 'closed';
}
private onFailure() {
this.failureCount++;
this.lastFailureTime = Date.now();
if (this.failureCount >= this.failureThreshold) {
this.state = 'open';
console.error('[CircuitBreaker] Circuit opened due to repeated failures');
}
}
}
verbosity: minimal
Create mock implementations for testing:
precision_write:
files:
- path: "src/lib/__mocks__/email.ts"
mode: fail_if_exists
content: |
import { SendEmailOptions } from '../email';
const sentEmails: Array<SendEmailOptions & { id: string }> = [];
export async function sendEmail(options: SendEmailOptions) {
const id = `mock-${Date.now()}`;
sentEmails.push({ ...options, id });
return { success: true, id };
}
export function getSentEmails() {
return sentEmails;
}
export function clearSentEmails() {
sentEmails.length = 0;
}
verbosity: minimal
Test webhook handlers locally:
precision_write:
files:
- path: "scripts/test-webhook.sh"
mode: fail_if_exists
content: |
#!/usr/bin/env bash
set -euo pipefail
# Requires: bash 4+
# Test Sanity webhook locally
echo "[INFO] Testing Sanity webhook..."
if curl -X POST http://localhost:3000/api/webhooks/sanity \
-H "Content-Type: application/json" \
-H "sanity-webhook-signature: $SANITY_WEBHOOK_SECRET" \
-d '{
"_type": "post",
"_id": "test-123",
"title": "Test Post"
}'; then
echo "[PASS] Webhook test successful"
else
echo "[FAIL] Webhook test failed"
exit 1
fi
verbosity: minimal
Run the validation script to ensure proper service integration:
precision_exec:
commands:
- cmd: "bash plugins/goodvibes/skills/outcome/service-integration/scripts/validate-services.sh ."
expect:
exit_code: 0
verbosity: standard
Always validate required environment variables at startup:
const requiredEnvVars = ['RESEND_API_KEY', 'SANITY_PROJECT_ID'];
for (const envVar of requiredEnvVars) {
if (!process.env[envVar]) {
throw new Error(`Missing required environment variable: ${envVar}`);
}
}
Implement token bucket rate limiting:
export class RateLimiter {
private tokens: number;
private lastRefill: number;
constructor(
private readonly maxTokens: number,
private readonly refillRatePerSecond: number
) {
this.tokens = maxTokens;
this.lastRefill = Date.now();
}
async acquire(): Promise<void> {
this.refill();
if (this.tokens < 1) {
const waitMs = (1 - this.tokens) * (1000 / this.refillRatePerSecond);
await new Promise(resolve => setTimeout(resolve, waitMs));
this.refill();
}
this.tokens -= 1;
}
private refill() {
const now = Date.now();
const elapsedSeconds = (now - this.lastRefill) / 1000;
const tokensToAdd = elapsedSeconds * this.refillRatePerSecond;
this.tokens = Math.min(this.maxTokens, this.tokens + tokensToAdd);
this.lastRefill = now;
}
}
BAD:
const resend = new Resend('re_abc123');
GOOD:
if (!process.env.RESEND_API_KEY) {
throw new Error('RESEND_API_KEY is required');
}
const resend = new Resend(process.env.RESEND_API_KEY);
BAD:
const result = await resend.emails.send(options);
return result.data;
GOOD:
const { data, error } = await resend.emails.send(options);
if (error) {
console.error('[Email] Send failed:', error);
throw new Error(`Email send failed: ${error.message}`);
}
return data;
BAD:
// Blocking user request
await sendEmail({ to: user.email, subject: 'Welcome' });
return res.json({ success: true });
ACCEPTABLE (for simple cases):
// Send email async
// Note: Fire-and-forget is only suitable for non-critical operations.
// For production: use a queue-based approach with retries (see BEST example below).
sendEmail({ to: user.email, subject: 'Welcome' })
.catch(err => console.error('[Email] Failed:', err));
return res.json({ success: true });
BEST (production-ready):
// Queue-based approach with retries
await emailQueue.add('welcome-email', {
to: user.email,
subject: 'Welcome'
});
return res.json({ success: true });
BAD:
export async function POST(request: NextRequest) {
const body = await request.json();
// Process without verification
}
GOOD:
import { timingSafeEqual } from 'crypto';
export async function POST(request: NextRequest) {
const signature = request.headers.get('webhook-signature');
// Validate webhook secret
const secret = process.env.WEBHOOK_SECRET;
if (!secret) {
return NextResponse.json({ error: 'Webhook secret not configured' }, { status: 500 });
}
const expected = Buffer.from(secret);
const received = Buffer.from(signature || '');
if (expected.length !== received.length || !timingSafeEqual(expected, received)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
const body = await request.json();
// Process
}
printf "%s\n" "${RESEND_API_KEY:0:5}..." (bash parameter expansion to display first 5 chars)resend.setDebug(true)Activates when the user asks about AI prompts, needs prompt templates, wants to search for prompts, or mentions prompts.chat. Use for discovering, retrieving, and improving prompts.
Search, retrieve, and install Agent Skills from the prompts.chat registry using MCP tools. Use when the user asks to find skills, browse skill catalogs, install a skill for Claude, or extend Claude's capabilities with reusable AI agent components.
Creating algorithmic art using p5.js with seeded randomness and interactive parameter exploration. Use this when users request creating art using code, generative art, algorithmic art, flow fields, or particle systems. Create original algorithmic art rather than copying existing artists' work to avoid copyright violations.