Reeve
DevelopersTutorials

AI Email Campaign Builder

Build an AI-powered email campaign builder that generates copy, pulls audience data, and saves campaigns to cloud storage.

Build an AI Email Campaign Builder

In this tutorial, you'll build a full email campaign creation tool. The app generates subject lines and email body copy using AI, lets the merchant pick a Klaviyo audience segment, previews the email, and saves drafts to Reeve cloud storage.

What you'll build:

  • Campaign brief form (product, tone, goal)
  • AI-generated subject line + email body with streaming output
  • Segment picker pulling live Klaviyo audiences
  • Draft management with save/load/delete via reeve.storage

APIs used: reeve.ai.stream, reeve.data.query, reeve.storage

Time: ~20 minutes


1. Scaffold and Configure

npx create-reeve-app email-builder --template react-app
cd email-builder
npm install
npm run dev

Update reeve-manifest.json:

{
  "id": "com.yourcompany.email-builder",
  "name": "AI Campaign Builder",
  "version": "1.0.0",
  "description": "Generate, preview, and save AI email campaigns in seconds.",
  "author": {
    "name": "Your Name",
    "email": "you@yourcompany.com"
  },
  "category": "email",
  "tags": ["klaviyo", "email", "ai", "campaigns"],
  "permissions": [
    "ai.complete",
    "ai.stream",
    "data.email",
    "data.shopify",
    "storage.read",
    "storage.write"
  ],
  "entrypoint": "index.html",
  "icon": "public/icon.png"
}

2. Build the Campaign Brief Form

Create src/CampaignForm.tsx:

interface CampaignBrief {
  productName: string;
  productUrl: string;
  tone: 'professional' | 'playful' | 'urgent' | 'friendly';
  goal: 'announce' | 'promote' | 'reengagement' | 'welcome';
  discount: string;
  audience: string;
}

const TONE_OPTIONS = [
  { value: 'professional', label: '🎩 Professional' },
  { value: 'playful', label: '🎉 Playful' },
  { value: 'urgent', label: '⚡ Urgent' },
  { value: 'friendly', label: '😊 Friendly' },
];

const GOAL_OPTIONS = [
  { value: 'announce', label: 'Product Announcement' },
  { value: 'promote', label: 'Promotional / Sale' },
  { value: 'reengagement', label: 'Win-Back / Re-engagement' },
  { value: 'welcome', label: 'Welcome Series' },
];

interface Props {
  onGenerate: (brief: CampaignBrief) => void;
  loading: boolean;
  segments: { id: string; name: string }[];
}

export function CampaignForm({ onGenerate, loading, segments }: Props) {
  const [brief, setBrief] = useState<CampaignBrief>({
    productName: '',
    productUrl: '',
    tone: 'friendly',
    goal: 'promote',
    discount: '',
    audience: '',
  });

  const update = (field: keyof CampaignBrief) =>
    (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) =>
      setBrief((b) => ({ ...b, [field]: e.target.value }));

  return (
    <form
      onSubmit={(e) => { e.preventDefault(); onGenerate(brief); }}
      className="campaign-form"
    >
      <h2>Campaign Brief</h2>

      <label>
        Product / Collection Name *
        <input value={brief.productName} onChange={update('productName')} required />
      </label>

      <label>
        Product URL
        <input
          type="url"
          value={brief.productUrl}
          onChange={update('productUrl')}
          placeholder="https://yourstore.com/products/..."
        />
      </label>

      <div className="two-col">
        <label>
          Tone
          <select value={brief.tone} onChange={update('tone')}>
            {TONE_OPTIONS.map((o) => (
              <option key={o.value} value={o.value}>{o.label}</option>
            ))}
          </select>
        </label>

        <label>
          Campaign Goal
          <select value={brief.goal} onChange={update('goal')}>
            {GOAL_OPTIONS.map((o) => (
              <option key={o.value} value={o.value}>{o.label}</option>
            ))}
          </select>
        </label>
      </div>

      <label>
        Discount / Offer (optional)
        <input
          value={brief.discount}
          onChange={update('discount')}
          placeholder="e.g. 20% off, free shipping"
        />
      </label>

      <label>
        Target Segment
        <select value={brief.audience} onChange={update('audience')}>
          <option value="">All subscribers</option>
          {segments.map((s) => (
            <option key={s.id} value={s.id}>{s.name}</option>
          ))}
        </select>
      </label>

      <button type="submit" disabled={loading || !brief.productName}>
        {loading ? 'Generating...' : '✨ Generate Campaign'}
      </button>
    </form>
  );
}

3. AI Generation with Streaming

Create src/useEmailGenerator.ts:

import { useState, useCallback } from 'react';
import { reeve } from '@reeve/app-sdk';

export interface GeneratedCampaign {
  subjectLine: string;
  previewText: string;
  body: string;
}

export function useEmailGenerator() {
  const [generating, setGenerating] = useState(false);
  const [output, setOutput] = useState('');
  const [campaign, setCampaign] = useState<GeneratedCampaign | null>(null);

  const generate = useCallback(async (brief: {
    productName: string;
    productUrl: string;
    tone: string;
    goal: string;
    discount: string;
  }) => {
    setGenerating(true);
    setOutput('');
    setCampaign(null);

    const prompt = `
Write a complete email campaign for:
- Product/Collection: ${brief.productName}
${brief.productUrl ? `- Product URL: ${brief.productUrl}` : ''}
- Campaign goal: ${brief.goal}
- Tone: ${brief.tone}
${brief.discount ? `- Offer: ${brief.discount}` : ''}

Output EXACTLY in this format:
SUBJECT: [subject line, max 50 chars]
PREVIEW: [preview text, max 90 chars]
---
[Full email body in HTML — use <p>, <strong>, <a> tags only]
    `.trim();

    let fullText = '';

    await reeve.ai.stream(
      {
        prompt,
        systemPrompt: `You are an expert ecommerce email copywriter. You write high-converting, 
brand-appropriate email campaigns. Keep subject lines punchy. 
Email body should be scannable — short paragraphs, clear CTA button.
Output valid HTML for the email body section only (no <html>/<head>/<body> wrapper).`,
        intent: 'generation',
        maxTokens: 800,
      },
      (chunk) => {
        fullText += chunk.delta;
        setOutput(fullText);

        if (chunk.done) {
          // Parse the structured output
          const lines = fullText.split('\n');
          const subjectLine = lines.find(l => l.startsWith('SUBJECT:'))
            ?.replace('SUBJECT:', '').trim() ?? '';
          const previewText = lines.find(l => l.startsWith('PREVIEW:'))
            ?.replace('PREVIEW:', '').trim() ?? '';
          const bodyStart = fullText.indexOf('---\n');
          const body = bodyStart >= 0 ? fullText.slice(bodyStart + 4).trim() : '';

          setCampaign({ subjectLine, previewText, body });
          setGenerating(false);
        }
      }
    );
  }, []);

  return { generate, generating, output, campaign };
}

4. Campaign Preview Component

Create src/CampaignPreview.tsx:

interface Props {
  subjectLine: string;
  previewText: string;
  body: string;
  onSave: () => void;
  onRegenerate: () => void;
  saving: boolean;
}

export function CampaignPreview({
  subjectLine, previewText, body, onSave, onRegenerate, saving
}: Props) {
  return (
    <div className="preview-pane">
      <div className="preview-header">
        <div>
          <div className="preview-label">Subject line</div>
          <div className="preview-subject">{subjectLine}</div>
          <div className="preview-label" style={{ marginTop: 8 }}>Preview text</div>
          <div className="preview-preview-text">{previewText}</div>
        </div>
        <div className="preview-actions">
          <button className="secondary" onClick={onRegenerate}>
            ↺ Regenerate
          </button>
          <button onClick={onSave} disabled={saving}>
            {saving ? 'Saving...' : '💾 Save Draft'}
          </button>
        </div>
      </div>

      <div className="email-preview">
        <div className="email-chrome">
          <div className="email-chrome-bar">
            <span>From: Your Store &lt;hello@yourstore.com&gt;</span>
            <span>Subject: {subjectLine}</span>
          </div>
          <div
            className="email-body"
            dangerouslySetInnerHTML={{ __html: body }}
          />
        </div>
      </div>
    </div>
  );
}

dangerouslySetInnerHTML is used here for the email preview because the AI generates HTML email content. In production, consider sanitizing with DOMPurify before rendering.


5. Draft Management with Cloud Storage

Create src/useDrafts.ts:

import { useState, useEffect, useCallback } from 'react';
import { reeve } from '@reeve/app-sdk';
import type { GeneratedCampaign } from './useEmailGenerator';

export interface CampaignDraft {
  id: string;
  name: string;
  brief: Record<string, string>;
  campaign: GeneratedCampaign;
  savedAt: string;
}

export function useDrafts() {
  const [drafts, setDrafts] = useState<CampaignDraft[]>([]);
  const [saving, setSaving] = useState(false);

  const loadDrafts = useCallback(async () => {
    const result = await reeve.storage.list<CampaignDraft>('email-drafts', {
      limit: 20,
      orderBy: '_meta.updatedAt',
      orderDir: 'desc',
    });
    setDrafts(result.items as CampaignDraft[]);
  }, []);

  useEffect(() => {
    loadDrafts();
  }, [loadDrafts]);

  const saveDraft = useCallback(async (
    brief: Record<string, string>,
    campaign: GeneratedCampaign
  ) => {
    setSaving(true);
    try {
      const id = `draft-${Date.now()}`;
      const draft: CampaignDraft = {
        id,
        name: `${brief.productName} — ${new Date().toLocaleDateString()}`,
        brief,
        campaign,
        savedAt: new Date().toISOString(),
      };
      await reeve.storage.set('email-drafts', id, draft);
      await loadDrafts();
      return id;
    } finally {
      setSaving(false);
    }
  }, [loadDrafts]);

  const deleteDraft = useCallback(async (id: string) => {
    await reeve.storage.delete('email-drafts', id);
    setDrafts((d) => d.filter((draft) => draft.id !== id));
  }, []);

  return { drafts, saving, saveDraft, deleteDraft, loadDrafts };
}

6. Wire It All Together

Replace src/App.tsx:

import { useEffect, useState } from 'react';
import { reeve } from '@reeve/app-sdk';
import { CampaignForm } from './CampaignForm';
import { CampaignPreview } from './CampaignPreview';
import { DraftList } from './DraftList';
import { useEmailGenerator } from './useEmailGenerator';
import { useDrafts } from './useDrafts';
import './App.css';

export default function App() {
  const [ready, setReady] = useState(false);
  const [segments, setSegments] = useState<{ id: string; name: string }[]>([]);
  const [currentBrief, setCurrentBrief] = useState<Record<string, string> | null>(null);
  const [view, setView] = useState<'compose' | 'drafts'>('compose');

  const { generate, generating, campaign } = useEmailGenerator();
  const { drafts, saving, saveDraft, deleteDraft } = useDrafts();

  useEffect(() => {
    async function init() {
      await reeve.init();

      // Load Klaviyo segments for the audience picker
      try {
        const result = await reeve.data.query({
          source: 'email',
          type: 'segments',
          limit: 50,
          fields: ['id', 'name'],
        });
        setSegments(result.data as { id: string; name: string }[]);
      } catch {
        // Klaviyo not connected — segments picker will show only "All"
      }

      setReady(true);
    }
    init();
  }, []);

  if (!ready) return <div className="loading">Connecting to Reeve...</div>;

  return (
    <div className="app-layout">
      <nav className="app-nav">
        <span className="app-logo">✉️ Campaign Builder</span>
        <div className="nav-tabs">
          <button
            className={view === 'compose' ? 'active' : ''}
            onClick={() => setView('compose')}
          >
            Compose
          </button>
          <button
            className={view === 'drafts' ? 'active' : ''}
            onClick={() => setView('drafts')}
          >
            Drafts ({drafts.length})
          </button>
        </div>
      </nav>

      {view === 'compose' && (
        <div className="compose-layout">
          <CampaignForm
            segments={segments}
            loading={generating}
            onGenerate={(brief) => {
              setCurrentBrief(brief as Record<string, string>);
              generate(brief);
            }}
          />

          {(generating || campaign) && (
            <div className="output-pane">
              {generating && !campaign && (
                <div className="streaming-output">
                  <div className="typing-indicator">✨ Generating...</div>
                </div>
              )}
              {campaign && (
                <CampaignPreview
                  {...campaign}
                  saving={saving}
                  onSave={() => saveDraft(currentBrief!, campaign)}
                  onRegenerate={() => generate(currentBrief as any)}
                />
              )}
            </div>
          )}
        </div>
      )}

      {view === 'drafts' && (
        <DraftList
          drafts={drafts}
          onDelete={deleteDraft}
          onLoad={(draft) => {
            setCurrentBrief(draft.brief);
            setView('compose');
          }}
        />
      )}
    </div>
  );
}

7. The Drafts List

Create src/DraftList.tsx:

import type { CampaignDraft } from './useDrafts';

interface Props {
  drafts: CampaignDraft[];
  onDelete: (id: string) => void;
  onLoad: (draft: CampaignDraft) => void;
}

export function DraftList({ drafts, onDelete, onLoad }: Props) {
  if (drafts.length === 0) {
    return (
      <div className="empty-state">
        <p>No saved drafts yet.</p>
        <p>Generate a campaign and click "Save Draft".</p>
      </div>
    );
  }

  return (
    <div className="draft-list">
      <h2>Saved Drafts</h2>
      {drafts.map((draft) => (
        <div key={draft.id} className="draft-card">
          <div className="draft-info">
            <div className="draft-name">{draft.name}</div>
            <div className="draft-subject">{draft.campaign.subjectLine}</div>
            <div className="draft-date">
              {new Date(draft.savedAt).toLocaleString()}
            </div>
          </div>
          <div className="draft-actions">
            <button className="secondary" onClick={() => onLoad(draft)}>
              Open
            </button>
            <button
              className="danger"
              onClick={() => {
                if (confirm('Delete this draft?')) onDelete(draft.id);
              }}
            >
              Delete
            </button>
          </div>
        </div>
      ))}
    </div>
  );
}

8. Run It

npm run dev

Install in the Cockpit and test:

  1. Fill in the campaign brief — enter a product name, choose a tone and goal
  2. Click Generate Campaign — watch the email stream in live
  3. Review the subject line and email preview
  4. Click Save Draft — it persists to cloud storage
  5. Switch to the Drafts tab — your campaign is there
  6. Reload the Cockpit — drafts survive (stored in cloud, not localStorage)

What's Next

  • Send via Klaviyo — integrate with the Klaviyo API to actually send the campaign
  • A/B subject lines — generate 3 subject options and let the merchant pick
  • Saved templates — store successful campaigns as reusable templates via reeve.storage
  • Segment analytics — show segment size from reeve.data.query before generating

On this page