> ## Documentation Index
> Fetch the complete documentation index at: https://docs.context.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Build Branded Email Templates

> Fetch logos, colors, fonts, images, and live screenshots from any domain, then have an LLM generate an on-brand HTML email template ready to send.

export const EmailTemplateDemo = () => {
  const KINDS = [{
    id: "welcome",
    label: "Welcome"
  }, {
    id: "upgrade",
    label: "Upgrade"
  }, {
    id: "trial",
    label: "Trial ending"
  }];
  const TRY = ["airbnb.com", "spotify.com", "stripe.com", "discord.com"];
  const DEFAULT_DOMAIN = "airbnb.com";
  const DEFAULT_KIND = "welcome";
  const DEFAULT_HTML = '<div style="max-width: 580px; margin: 0 auto; font-family: \'Airbnb Cereal VF\', -apple-system, BlinkMacSystemFont, \'Segoe UI\', Helvetica, Arial, sans-serif; background: #ffffff;">\n\n  <div style="padding: 28px 32px 20px 32px; border-bottom: 1px solid #f2f2f2;">\n    <img src="https://media.brand.dev/febe2f1a-31f3-4453-8608-a79d8966ef27.svg" alt="Airbnb" width="96" style="display: block;" />\n  </div>\n\n  <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background: linear-gradient(135deg, #fc3c5c 0%, #fb5c74 55%, #fc9cb4 100%);">\n    <tr>\n      <td style="padding: 56px 36px 64px 36px;">\n        <div style="font-size: 12px; letter-spacing: 3px; text-transform: uppercase; color: rgba(255,255,255,0.85); margin-bottom: 18px; font-weight: 500;">Welcome, traveler</div>\n        <div style="font-size: 56px; line-height: 1.02; font-weight: 800; color: #ffffff; letter-spacing: -1.5px;">Belong<br/>Anywhere.</div>\n        <div style="margin-top: 28px; width: 48px; height: 4px; background: #ffffff; border-radius: 4px;"></div>\n      </td>\n    </tr>\n  </table>\n\n  <div style="padding: 44px 36px 12px 36px;">\n    <p style="font-size: 17px; line-height: 1.6; color: #222222; margin: 0 0 18px 0;">Your Airbnb account is live. You\'ve just unlocked a community of over 5 million hosts across nearly every corner of the world.</p>\n    <p style="font-size: 17px; line-height: 1.6; color: #6a6a6a; margin: 0 0 32px 0;">Tell us where you\'d love to wake up next — a cabin in the pines, a loft in Lisbon, a treehouse you\'ll talk about for years. We\'ll handle the rest.</p>\n\n    <a href="https://airbnb.com" style="display: inline-block; background: #e00b41; color: #ffffff; text-decoration: none; padding: 16px 38px; border-radius: 50px; font-size: 16px; font-weight: 600; letter-spacing: 0.2px;">Start exploring</a>\n  </div>\n\n  <div style="padding: 44px 36px 36px 36px;">\n    <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse: collapse;">\n      <tr>\n        <td width="33%" style="padding-right: 12px; vertical-align: top;">\n          <div style="font-size: 32px; font-weight: 800; color: #fc3c5c; letter-spacing: -1px;">5M+</div>\n          <div style="font-size: 13px; color: #6a6a6a; margin-top: 4px; line-height: 1.4;">Hosts ready<br/>to welcome you</div>\n        </td>\n        <td width="33%" style="padding: 0 12px; vertical-align: top; border-left: 1px solid #f0f0f0; border-right: 1px solid #f0f0f0;">\n          <div style="font-size: 32px; font-weight: 800; color: #fc3c5c; letter-spacing: -1px; padding-left: 12px;">220</div>\n          <div style="font-size: 13px; color: #6a6a6a; margin-top: 4px; line-height: 1.4; padding-left: 12px;">Countries and<br/>regions</div>\n        </td>\n        <td width="33%" style="padding-left: 12px; vertical-align: top;">\n          <div style="font-size: 32px; font-weight: 800; color: #fc3c5c; letter-spacing: -1px;">1.5B</div>\n          <div style="font-size: 13px; color: #6a6a6a; margin-top: 4px; line-height: 1.4;">Guest arrivals<br/>and counting</div>\n        </td>\n      </tr>\n    </table>\n  </div>\n\n  <div style="padding: 28px 36px 40px 36px; border-top: 1px solid #f2f2f2;">\n    <div style="font-size: 13px; color: #6a6a6a; line-height: 1.7;">\n      Sent with care from Airbnb.<br/>\n      <a href="https://airbnb.com" style="color: #222222; text-decoration: none; font-weight: 600;">airbnb.com</a>\n      &nbsp;·&nbsp;\n      <a href="https://airbnb.com" style="color: #6a6a6a; text-decoration: underline;">Privacy</a>\n      &nbsp;·&nbsp;\n      <a href="https://airbnb.com" style="color: #6a6a6a; text-decoration: underline;">Terms</a>\n    </div>\n  </div>\n\n</div>';
  const DEFAULT_BRAND = {
    title: "Airbnb",
    domain: "airbnb.com",
    colors: [{
      hex: "#fc3c5c"
    }, {
      hex: "#fc9cb4"
    }, {
      hex: "#fb5c74"
    }]
  };
  const STATIC = {
    "airbnb.com:welcome": {
      html: DEFAULT_HTML,
      brand: DEFAULT_BRAND
    },
    "airbnb.com:upgrade": {
      html: '<div style="font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Helvetica, Arial, sans-serif; max-width: 560px; margin: 0 auto; background: #ffffff; color: #18181b;">\n  <div style="background: #fc3c5c; height: 6px; width: 100%;"></div>\n  <div style="padding: 32px 40px 24px 40px;">\n    <img src="https://media.brand.dev/febe2f1a-31f3-4453-8608-a79d8966ef27.svg" alt="Airbnb" style="max-height: 28px; display: block; margin-bottom: 32px;">\n    <h1 style="font-size: 28px; font-weight: 700; color: #fc3c5c; margin: 0 0 20px 0; line-height: 1.2;">Host more. Earn more. Go Plus.</h1>\n    <p style="font-size: 16px; line-height: 1.6; margin: 0 0 16px 0; color: #18181b;">You\'ve been hosting on the free plan — and your listings are doing great. Upgrade to <span style="font-weight: 600; color: #fc3c5c;">Airbnb Host Plus</span> to unlock professional photography credits, priority placement in search, and smart pricing that adjusts nightly rates for you.</p>\n    <p style="font-size: 16px; line-height: 1.6; margin: 0 0 28px 0; color: #18181b;">Plus hosts see an average of 23% more bookings in their first three months. Your calendar, payouts, and reviews carry over instantly — nothing to set up.</p>\n    <a href="https://airbnb.com" style="display: inline-block; background: #fc3c5c; color: #ffffff; text-decoration: none; font-size: 15px; font-weight: 600; padding: 14px 32px; border-radius: 8px; letter-spacing: 0.2px;">Upgrade to Host Plus</a>\n  </div>\n  <div style="margin: 0 40px; border-top: 2px solid #fc9cb4;"></div>\n  <table role="presentation" width="100%" cellpadding="0" cellspacing="0">\n    <tr>\n      <td align="left" style="padding: 20px 0 28px 40px; font-size: 13px; color: #71717a;">© 2024 Airbnb, Inc. All rights reserved.</td>\n      <td align="right" style="padding: 20px 40px 28px 0;"><a href="https://airbnb.com" style="font-size: 13px; color: #fc3c5c; text-decoration: none; font-weight: 500;">airbnb.com</a></td>\n    </tr>\n  </table>\n</div>',
      brand: {
        title: "Airbnb",
        domain: "airbnb.com",
        colors: [{
          hex: "#fc3c5c"
        }, {
          hex: "#fc9cb4"
        }, {
          hex: "#fb5c74"
        }]
      }
    },
    "airbnb.com:trial": {
      html: '<div style="font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Helvetica, Arial, sans-serif; max-width: 560px; margin: 0 auto; background: #ffffff; color: #18181b;">\n  <!-- Top color strip -->\n  <div style="height: 6px; background: #fc3c5c; border-radius: 0;"></div>\n\n  <!-- Header / Logo area -->\n  <table role="presentation" width="100%" cellpadding="0" cellspacing="0">\n    <tr>\n      <td style="padding: 28px 32px 20px 32px;">\n        <img src="https://media.brand.dev/febe2f1a-31f3-4453-8608-a79d8966ef27.svg" alt="Airbnb" style="max-height: 28px; display: block;" />\n      </td>\n    </tr>\n  </table>\n\n  <!-- Hero banner -->\n  <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background: linear-gradient(135deg, #fc3c5c 0%, #fb5c74 60%, #fc9cb4 100%);">\n    <tr>\n      <td align="center" style="padding: 40px 32px 36px 32px; text-align: center;">\n        <h1 style="margin: 0 0 8px 0; font-size: 28px; font-weight: 700; color: #ffffff; letter-spacing: -0.5px; line-height: 1.25;">Your Host Plus trial ends in 3 days.</h1>\n        <p style="margin: 0; font-size: 15px; color: rgba(255,255,255,0.88); font-weight: 400;">Keep priority placement, smart pricing, and photography credits.</p>\n      </td>\n    </tr>\n  </table>\n\n  <!-- Body content -->\n  <div style="padding: 36px 32px 28px 32px;">\n    <p style="margin: 0 0 16px 0; font-size: 16px; line-height: 1.65; color: #18181b;">\n      Your 30-day Host Plus trial wraps up on Friday. During the trial your listings earned priority placement in search and your nightly rates were tuned automatically by smart pricing — features that go away when the trial ends.\n    </p>\n    <p style="margin: 0 0 32px 0; font-size: 16px; line-height: 1.65; color: #18181b;">\n      Subscribe before Friday and nothing changes: your placement, pricing rules, and remaining photography credits all carry over. If you do nothing, your account simply returns to the free hosting plan.\n    </p>\n\n    <!-- CTA Button -->\n    <div style="text-align: center;">\n      <a href="https://airbnb.com" style="display: inline-block; background: #fc3c5c; color: #ffffff; text-decoration: none; font-size: 16px; font-weight: 600; padding: 14px 36px; border-radius: 8px; letter-spacing: 0.2px;">Keep Host Plus</a>\n    </div>\n  </div>\n\n  <!-- Divider -->\n  <div style="margin: 0 32px; height: 1px; background: #fc9cb4; opacity: 0.5;"></div>\n\n  <!-- Footer -->\n  <div style="padding: 20px 32px 28px 32px; text-align: center;">\n    <p style="margin: 0 0 6px 0; font-size: 13px; color: #71717a;">You\'re receiving this because you started a Host Plus trial at</p>\n    <a href="https://airbnb.com" style="font-size: 13px; color: #fc3c5c; text-decoration: none; font-weight: 500;">airbnb.com</a>\n    <p style="margin: 10px 0 0 0; font-size: 12px; color: #a1a1aa;">© 2024 Airbnb, Inc. · Belong Anywhere</p>\n  </div>\n</div>',
      brand: {
        title: "Airbnb",
        domain: "airbnb.com",
        colors: [{
          hex: "#fc3c5c"
        }, {
          hex: "#fc9cb4"
        }, {
          hex: "#fb5c74"
        }]
      }
    },
    "spotify.com:welcome": {
      html: '<div style="font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Helvetica, Arial, sans-serif; max-width: 560px; margin: 0 auto; background: #ffffff; color: #18181b;">\n  <!-- Top color strip -->\n  <div style="height: 6px; background: #1cd462; border-radius: 0;"></div>\n\n  <!-- Header -->\n  <table role="presentation" cellpadding="0" cellspacing="0" style="margin: 28px 32px 20px 32px;">\n    <tr>\n      <td style="padding-right: 10px;"><img src="https://media.brand.dev/b3a3572c-6834-4800-b17c-6a3ee0c063d8.png" alt="Spotify" width="36" height="36" style="display: block;" /></td>\n      <td style="font-size: 20px; font-weight: 700; letter-spacing: -0.5px; color: #18181b; vertical-align: middle;">Spotify</td>\n    </tr>\n  </table>\n\n  <!-- Hero / Headline -->\n  <div style="padding: 8px 32px 0 32px;">\n    <h1 style="margin: 0 0 16px 0; font-size: 30px; font-weight: 800; line-height: 1.2; color: #1cd462; letter-spacing: -0.5px;">\n      Welcome to Spotify. 🎧\n    </h1>\n  </div>\n\n  <!-- Body copy -->\n  <div style="padding: 0 32px;">\n    <p style="margin: 0 0 14px 0; font-size: 15px; line-height: 1.65; color: #3f3f46;">\n      Your account is live and your soundtrack awaits. With millions of songs, podcasts, and audiobooks at your fingertips, Spotify is built for every single moment — whether you\'re powering through a workout, winding down at night, or discovering your next obsession.\n    </p>\n    <p style="margin: 0 0 28px 0; font-size: 15px; line-height: 1.65; color: #3f3f46;">\n      Dive in and let our personalized playlists and recommendations do the heavy lifting — your taste is unique, and Spotify is here to match it.\n    </p>\n  </div>\n\n  <!-- CTA Button -->\n  <div style="padding: 0 32px 36px 32px;">\n    <a href="https://spotify.com" style="display: inline-block; background: #1cd462; color: #0a2e18; text-decoration: none; font-size: 15px; font-weight: 700; padding: 14px 32px; border-radius: 50px; letter-spacing: 0.2px;">\n      Start Listening Now →\n    </a>\n  </div>\n\n  <!-- Divider -->\n  <div style="margin: 0 32px; border-top: 2px solid #207f3f; opacity: 0.18;"></div>\n\n  <!-- Footer -->\n  <table role="presentation" width="100%" cellpadding="0" cellspacing="0">\n    <tr>\n      <td align="left" style="padding: 20px 0 28px 32px; font-size: 12px; color: #a1a1aa;">© 2024 Spotify. All rights reserved.</td>\n      <td align="right" style="padding: 20px 32px 28px 0;"><a href="https://spotify.com" style="font-size: 12px; color: #207f3f; text-decoration: none; font-weight: 600;">spotify.com</a></td>\n    </tr>\n  </table>\n  <!-- Bottom color strip -->\n  <div style="height: 4px; background: linear-gradient(90deg, #1cd462 0%, #207f3f 100%);"></div>\n</div>',
      brand: {
        title: "Spotify",
        domain: "spotify.com",
        colors: [{
          hex: "#1cd462"
        }, {
          hex: "#207f3f"
        }]
      }
    },
    "spotify.com:upgrade": {
      html: '<div style="font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Helvetica, Arial, sans-serif; max-width: 560px; margin: 0 auto; background: #ffffff; color: #18181b;">\n  <!-- Top color strip -->\n  <div style="height: 6px; background: #1cd462; border-radius: 0;"></div>\n\n  <!-- Header / Logo area -->\n  <table role="presentation" cellpadding="0" cellspacing="0" style="margin: 32px 40px 24px 40px;">\n    <tr>\n      <td style="padding-right: 10px;"><img src="https://media.brand.dev/b3a3572c-6834-4800-b17c-6a3ee0c063d8.png" alt="Spotify" width="36" height="36" style="display: block;" /></td>\n      <td style="font-size: 22px; font-weight: 700; letter-spacing: -0.3px; color: #18181b; vertical-align: middle;">Spotify</td>\n    </tr>\n  </table>\n\n  <!-- Hero / Main content -->\n  <div style="padding: 0 40px 32px 40px;">\n    <!-- Accent bar beside headline -->\n    <table role="presentation" cellpadding="0" cellspacing="0" style="margin-bottom: 24px;">\n      <tr>\n        <td width="4" style="background: #1cd462; border-radius: 4px;">&nbsp;</td>\n        <td style="padding-left: 16px;">\n          <h1 style="margin: 0; font-size: 28px; font-weight: 800; line-height: 1.25; color: #18181b; letter-spacing: -0.5px;">\n            Go Premium.<br />\n            <span style="color: #1cd462;">Ads off. Volume up.</span>\n          </h1>\n        </td>\n      </tr>\n    </table>\n\n    <!-- Paragraph 1 -->\n    <p style="margin: 0 0 16px 0; font-size: 15px; line-height: 1.65; color: #3f3f46;">\n      You\'ve been listening free — nice. But every ad break is a beat you didn\'t hear. Upgrade to Spotify Premium for ad-free listening, offline downloads for the subway and the sky, and unlimited skips when the shuffle isn\'t reading the room.\n    </p>\n\n    <!-- Paragraph 2 -->\n    <p style="margin: 0 0 32px 0; font-size: 15px; line-height: 1.65; color: #3f3f46;">\n      Premium also unlocks the highest audio quality we offer, so your playlists sound the way the artists intended. Your library, follows, and Discover Weekly come with you — just without the interruptions.\n    </p>\n\n    <!-- CTA Button -->\n    <a href="https://spotify.com" style="display: inline-block; background: #1cd462; color: #0a2e18; text-decoration: none; font-size: 15px; font-weight: 700; padding: 14px 32px; border-radius: 500px; letter-spacing: 0.2px;">\n      Get Premium →\n    </a>\n  </div>\n\n  <!-- Divider -->\n  <div style="margin: 0 40px; height: 1px; background: #207f3f; opacity: 0.2;"></div>\n\n  <!-- Footer -->\n  <table role="presentation" width="100%" cellpadding="0" cellspacing="0">\n    <tr>\n      <td align="left" style="padding: 24px 0 32px 40px; font-size: 12px; color: #a1a1aa;">© 2024 Spotify. All rights reserved.</td>\n      <td align="right" style="padding: 24px 40px 32px 0;"><a href="https://spotify.com" style="font-size: 12px; color: #207f3f; text-decoration: none; font-weight: 600;">spotify.com</a></td>\n    </tr>\n  </table>\n\n  <!-- Bottom accent strip -->\n  <div style="height: 3px; background: linear-gradient(90deg, #1cd462 0%, #207f3f 100%);"></div>\n</div>',
      brand: {
        title: "Spotify",
        domain: "spotify.com",
        colors: [{
          hex: "#1cd462"
        }, {
          hex: "#207f3f"
        }]
      }
    },
    "spotify.com:trial": {
      html: '<div style="font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Helvetica, Arial, sans-serif; max-width: 560px; margin: 0 auto; background: #ffffff; color: #18181b;">\n  <!-- Top color strip -->\n  <div style="height: 6px; background: #1cd462; border-radius: 4px 4px 0 0;"></div>\n\n  <!-- Header / Logo area -->\n  <table role="presentation" cellpadding="0" cellspacing="0" style="margin: 28px 32px 20px 32px;">\n    <tr>\n      <td style="padding-right: 10px;"><img src="https://media.brand.dev/b3a3572c-6834-4800-b17c-6a3ee0c063d8.png" alt="Spotify" width="36" height="36" style="display: block; border-radius: 50%;" /></td>\n      <td style="font-size: 20px; font-weight: 700; letter-spacing: -0.3px; color: #18181b; vertical-align: middle;">Spotify</td>\n    </tr>\n  </table>\n\n  <!-- Hero banner -->\n  <table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="margin-bottom: 28px;">\n    <tr>\n      <td style="padding: 0 32px;">\n        <table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background: linear-gradient(135deg, #1cd462 0%, #207f3f 100%); border-radius: 12px;">\n          <tr>\n            <td style="padding: 36px 32px;">\n              <h1 style="margin: 0 0 8px 0; font-size: 28px; font-weight: 800; color: #ffffff; line-height: 1.2; letter-spacing: -0.5px;">Premium ends in 3 days ⏳</h1>\n              <p style="margin: 0; font-size: 14px; color: rgba(255,255,255,0.85); font-style: italic; font-weight: 400;">Keep the music going — without the ads.</p>\n            </td>\n          </tr>\n        </table>\n      </td>\n    </tr>\n  </table>\n\n  <!-- Body content -->\n  <div style="padding: 0 32px 28px 32px;">\n    <p style="margin: 0 0 16px 0; font-size: 16px; line-height: 1.65; color: #18181b;">\n      Your free Premium trial ends on Thursday. After that, ads return between songs, offline downloads stop syncing, and skips go back to being limited — your playlists and follows stay, but the experience changes.\n    </p>\n    <p style="margin: 0 0 28px 0; font-size: 16px; line-height: 1.65; color: #3f3f46;">\n      Keep Premium and nothing is interrupted: ad-free listening, downloads for offline trips, unlimited skips, and the highest audio quality we offer. Cancel anytime — no long-term commitment.\n    </p>\n\n    <!-- CTA Button -->\n    <a href="https://spotify.com" style="display: inline-block; background: #1cd462; color: #0a3d1f; text-decoration: none; font-weight: 700; font-size: 15px; padding: 14px 32px; border-radius: 500px; letter-spacing: 0.3px;">Keep Premium</a>\n  </div>\n\n  <!-- Divider -->\n  <div style="margin: 0 32px; height: 1px; background: #207f3f; opacity: 0.18;"></div>\n\n  <!-- Footer -->\n  <table role="presentation" width="100%" cellpadding="0" cellspacing="0">\n    <tr>\n      <td align="left" style="padding: 20px 0 28px 32px; font-size: 12px; color: #a1a1aa;">© 2024 Spotify. All rights reserved.</td>\n      <td align="right" style="padding: 20px 32px 28px 0;"><a href="https://spotify.com" style="font-size: 12px; color: #207f3f; text-decoration: none; font-weight: 600;">spotify.com</a></td>\n    </tr>\n  </table>\n</div>',
      brand: {
        title: "Spotify",
        domain: "spotify.com",
        colors: [{
          hex: "#1cd462"
        }, {
          hex: "#207f3f"
        }]
      }
    },
    "stripe.com:welcome": {
      html: '<div style="font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Helvetica, Arial, sans-serif; max-width: 560px; margin: 0 auto; background: #ffffff; color: #18181b;">\n  <div style="height: 6px; background: #543cfb; border-radius: 4px 4px 0 0;"></div>\n  <div style="padding: 36px 40px 0 40px;">\n    <img src="https://media.brand.dev/9238e986-4521-4b59-acec-645c944f0db3.svg" alt="Stripe" style="max-height: 28px; display: block; margin-bottom: 36px;">\n    <h1 style="font-size: 26px; font-weight: 700; color: #543cfb; margin: 0 0 20px 0; line-height: 1.2;">Welcome to Stripe.</h1>\n    <p style="font-size: 15px; line-height: 1.6; margin: 0 0 16px 0; color: #18181b;">Your account is ready — you now have access to the financial infrastructure that millions of businesses use to accept payments, issue cards, manage subscriptions, and build exactly the revenue model they need.</p>\n    <p style="font-size: 15px; line-height: 1.6; margin: 0 0 32px 0; color: #18181b;">From powerful APIs to real-time analytics with Sigma, fraud prevention with Radar, and seamless billing — everything you need to grow your revenue is already inside your dashboard.</p>\n    <a href="https://dashboard.stripe.com" style="display: inline-block; background: #543cfb; color: #ffffff; text-decoration: none; font-size: 15px; font-weight: 600; padding: 13px 28px; border-radius: 6px; margin-bottom: 40px; letter-spacing: 0.01em;">Go to your Dashboard →</a>\n  </div>\n  <div style="border-top: 2px solid #a498ec; margin: 0 40px;"></div>\n  <table role="presentation" width="100%" cellpadding="0" cellspacing="0">\n    <tr>\n      <td align="left" style="padding: 24px 0 24px 40px; font-size: 12px; color: #71717a;">© 2025 Stripe, Inc.</td>\n      <td align="right" style="padding: 24px 40px 24px 0;"><a href="https://stripe.com" style="font-size: 12px; color: #543cfb; text-decoration: none; font-weight: 500;">stripe.com</a></td>\n    </tr>\n  </table>\n  <div style="padding: 0 40px 28px 40px;">\n    <p style="font-size: 11px; color: #a1a1aa; margin: 0; line-height: 1.5;">You\'re receiving this email because you recently created a Stripe account. If this wasn\'t you, please contact <a href="mailto:support@stripe.com" style="color: #543cfb; text-decoration: none;">support@stripe.com</a>.</p>\n  </div>\n</div>',
      brand: {
        title: "Stripe",
        domain: "stripe.com",
        colors: [{
          hex: "#543cfb"
        }, {
          hex: "#a498ec"
        }, {
          hex: "#041424"
        }]
      }
    },
    "stripe.com:upgrade": {
      html: '<div style="font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Helvetica, Arial, sans-serif; max-width: 560px; margin: 0 auto; background: #ffffff; color: #18181b;">\n  <div style="height: 6px; background: #543cfb; border-radius: 4px 4px 0 0;"></div>\n  <div style="padding: 36px 40px 28px 40px;">\n    <div style="margin-bottom: 32px;">\n      <img src="https://media.brand.dev/9238e986-4521-4b59-acec-645c944f0db3.svg" alt="Stripe" style="max-height: 28px; display: block;">\n    </div>\n    <h1 style="font-size: 26px; font-weight: 700; color: #543cfb; margin: 0 0 20px 0; line-height: 1.25; letter-spacing: -0.4px;">Your volume has outgrown Standard.</h1>\n    <p style="font-size: 15px; line-height: 1.65; color: #3f3f46; margin: 0 0 16px 0;">\n      You processed more than $50,000 last month — at that scale, Stripe Scale pays for itself. Unlock volume discounts on every transaction, advanced revenue recognition, and Sigma\'s full SQL analytics over all of your payments data.\n    </p>\n    <p style="font-size: 15px; line-height: 1.65; color: #3f3f46; margin: 0 0 32px 0;">\n      Upgrading takes effect immediately: your integration, API keys, and webhooks stay exactly as they are, and Radar\'s machine-learning fraud rules get the priority-tuning tier. You\'ll also get a dedicated support channel with 4-hour response times.\n    </p>\n    <a href="https://stripe.com/dashboard" style="display: inline-block; background: #543cfb; color: #ffffff; font-size: 15px; font-weight: 600; text-decoration: none; padding: 13px 28px; border-radius: 6px; letter-spacing: 0.1px;">Upgrade to Scale →</a>\n  </div>\n  <div style="margin: 0 40px; border-top: 2px solid #a498ec; opacity: 0.5;"></div>\n  <table role="presentation" width="100%" cellpadding="0" cellspacing="0">\n    <tr>\n      <td align="left" style="padding: 20px 0 32px 40px; font-size: 12px; color: #71717a;">© 2025 Stripe, Inc.</td>\n      <td align="right" style="padding: 20px 40px 32px 0;"><a href="https://stripe.com" style="font-size: 12px; color: #543cfb; text-decoration: none; font-weight: 500;">stripe.com</a></td>\n    </tr>\n  </table>\n</div>',
      brand: {
        title: "Stripe",
        domain: "stripe.com",
        colors: [{
          hex: "#543cfb"
        }, {
          hex: "#a498ec"
        }, {
          hex: "#041424"
        }]
      }
    },
    "stripe.com:trial": {
      html: '<div style="font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Helvetica, Arial, sans-serif; max-width: 560px; margin: 0 auto; background: #ffffff; color: #18181b;">\n  <div style="height: 6px; background: #543cfb; border-radius: 4px 4px 0 0;"></div>\n  <div style="padding: 32px 40px 24px 40px;">\n    <div style="margin-bottom: 32px;">\n      <img src="https://media.brand.dev/9238e986-4521-4b59-acec-645c944f0db3.svg" alt="Stripe" style="max-height: 28px; display: block;" />\n    </div>\n    <h1 style="font-size: 26px; font-weight: 700; color: #543cfb; margin: 0 0 20px 0; line-height: 1.25; letter-spacing: -0.5px;">Your Scale trial ends in 5 days.</h1>\n    <p style="font-size: 15px; line-height: 1.65; margin: 0 0 16px 0; color: #18181b;">\n      On June 16 your account returns to the Standard plan. That means Sigma\'s SQL analytics, advanced revenue recognition, and Radar\'s priority-tuning tier will be switched off — saved Sigma queries are kept for 30 days, but they won\'t run until you upgrade.\n    </p>\n    <p style="font-size: 15px; line-height: 1.65; margin: 0 0 28px 0; color: #3f3f46;">\n      Payments are never interrupted: your integration, API keys, and webhooks keep working either way. To keep the Scale features your team has been using, add a payment method before the trial ends.\n    </p>\n    <a href="https://stripe.com/dashboard" style="display: inline-block; background: #543cfb; color: #ffffff; text-decoration: none; font-size: 15px; font-weight: 600; padding: 13px 28px; border-radius: 6px; letter-spacing: 0.1px;">Keep Scale features →</a>\n  </div>\n  <div style="margin: 8px 40px 0 40px; border-top: 1.5px solid #a498ec;"></div>\n  <table role="presentation" width="100%" cellpadding="0" cellspacing="0">\n    <tr>\n      <td align="left" style="padding: 20px 0 32px 40px; font-size: 12px; color: #71717a;">© 2025 Stripe, Inc.</td>\n      <td align="right" style="padding: 20px 40px 32px 0;"><a href="https://stripe.com" style="font-size: 12px; color: #543cfb; text-decoration: none; font-weight: 500;">stripe.com</a></td>\n    </tr>\n  </table>\n</div>',
      brand: {
        title: "Stripe",
        domain: "stripe.com",
        colors: [{
          hex: "#543cfb"
        }, {
          hex: "#a498ec"
        }, {
          hex: "#041424"
        }]
      }
    },
    "discord.com:welcome": {
      html: '<div style="font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Helvetica, Arial, sans-serif; max-width: 560px; margin: 0 auto; background: #ffffff; color: #18181b;">\n\n  <!-- Top color strip -->\n  <div style="height: 6px; background: #141cbc; border-radius: 4px 4px 0 0;"></div>\n\n  <!-- Header / Logo area -->\n  <table role="presentation" cellpadding="0" cellspacing="0" style="margin: 28px 36px 20px 36px;">\n    <tr>\n      <td style="padding-right: 12px;"><img src="https://media.brand.dev/2aa75795-9fbe-417f-897e-f91467a6d676.jpg" alt="Discord" width="40" height="40" style="display: block; border-radius: 10px; background: #141cbc;" /></td>\n      <td style="font-size: 20px; font-weight: 700; color: #141cbc; letter-spacing: -0.3px; vertical-align: middle;">Discord</td>\n    </tr>\n  </table>\n\n  <!-- Hero / Main content -->\n  <div style="padding: 8px 36px 32px 36px;">\n\n    <!-- Headline -->\n    <h1 style="margin: 0 0 20px 0; font-size: 28px; font-weight: 800; color: #141cbc; line-height: 1.2; letter-spacing: -0.5px;">\n      Welcome to the party. 🎉\n    </h1>\n\n    <!-- Paragraph 1 -->\n    <p style="margin: 0 0 14px 0; font-size: 15px; line-height: 1.65; color: #3f3f46;">\n      You\'ve just joined the place <em>where group chat is all fun &amp; games</em> — a platform built to turn every conversation into something worth showing up for, whether you\'re coordinating raids, watching streams, or just hanging out.\n    </p>\n\n    <!-- Paragraph 2 -->\n    <p style="margin: 0 0 32px 0; font-size: 15px; line-height: 1.65; color: #3f3f46;">\n      Your account is ready and your next great community is one click away. Explore servers, create your own space, and make it entirely yours — Discord is whatever you need it to be.\n    </p>\n\n    <!-- CTA Button -->\n    <a href="https://discord.com" style="display: inline-block; background: #141cbc; color: #ffffff; text-decoration: none; font-size: 15px; font-weight: 700; padding: 14px 32px; border-radius: 8px; letter-spacing: 0.2px;">\n      Open Discord →\n    </a>\n\n  </div>\n\n  <!-- Divider -->\n  <div style="margin: 0 36px; height: 1px; background: #848cd4; opacity: 0.35;"></div>\n\n  <!-- Footer -->\n  <table role="presentation" width="100%" cellpadding="0" cellspacing="0">\n    <tr>\n      <td align="left" style="padding: 20px 0 28px 36px; font-size: 12px; color: #848cd4; font-weight: 600; letter-spacing: 0.3px;">discord.com</td>\n      <td align="right" style="padding: 20px 36px 28px 0; font-size: 12px; color: #a1a1aa;">© 2024 Discord Inc. All rights reserved.</td>\n    </tr>\n  </table>\n\n</div>',
      brand: {
        title: "Discord",
        domain: "discord.com",
        colors: [{
          hex: "#141cbc"
        }, {
          hex: "#848cd4"
        }]
      }
    },
    "discord.com:upgrade": {
      html: '<div style="font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Helvetica, Arial, sans-serif; max-width: 560px; margin: 0 auto; background: #ffffff; color: #18181b;">\n  <!-- Top color strip -->\n  <div style="height: 6px; background: #141cbc; border-radius: 0;"></div>\n\n  <!-- Main card -->\n  <div style="padding: 36px 40px 28px 40px;">\n\n    <!-- Logo area -->\n    <table role="presentation" cellpadding="0" cellspacing="0" style="margin-bottom: 32px;">\n      <tr>\n        <td style="padding-right: 12px;"><img src="https://media.brand.dev/2aa75795-9fbe-417f-897e-f91467a6d676.jpg" alt="Discord icon" width="40" height="40" style="display: block; border-radius: 10px;" /></td>\n        <td style="font-size: 20px; font-weight: 700; color: #141cbc; letter-spacing: -0.3px; vertical-align: middle;">Discord</td>\n      </tr>\n    </table>\n\n    <!-- Headline -->\n    <h1 style="margin: 0 0 20px 0; font-size: 28px; font-weight: 800; color: #141cbc; line-height: 1.2; letter-spacing: -0.5px;">\n      Level up with Nitro 🚀\n    </h1>\n\n    <!-- Body copy -->\n    <p style="margin: 0 0 16px 0; font-size: 16px; line-height: 1.65; color: #3f3f46;">\n      Your servers are popping off — time to upgrade the experience. <em>Discord Nitro</em> unlocks 4K streaming, 500MB uploads, custom emoji anywhere, animated avatars, and two free server boosts to share with your favorite communities.\n    </p>\n    <p style="margin: 0 0 32px 0; font-size: 16px; line-height: 1.65; color: #3f3f46;">\n      Everything about your account stays the same — your servers, friends, and messages — you just get more ways to make it yours. Upgrade in two clicks and the perks kick in instantly.\n    </p>\n\n    <!-- CTA Button -->\n    <a href="https://discord.com" style="display: inline-block; background: #141cbc; color: #ffffff; text-decoration: none; font-size: 15px; font-weight: 700; padding: 14px 32px; border-radius: 8px; letter-spacing: 0.2px;">\n      Get Nitro →\n    </a>\n\n  </div>\n\n  <!-- Divider -->\n  <div style="height: 2px; background: #848cd4; margin: 0 40px; border-radius: 2px; opacity: 0.35;"></div>\n\n  <!-- Footer -->\n  <table role="presentation" width="100%" cellpadding="0" cellspacing="0">\n    <tr>\n      <td align="left" style="padding: 20px 0 28px 40px; font-size: 12px; color: #a1a1aa;">© 2024 Discord · <a href="https://discord.com" style="color: #848cd4; text-decoration: none; font-weight: 500;">discord.com</a></td>\n      <td align="right" style="padding: 20px 40px 28px 0; font-size: 12px; color: #a1a1aa;">You\'re receiving this because you signed up.</td>\n    </tr>\n  </table>\n</div>',
      brand: {
        title: "Discord",
        domain: "discord.com",
        colors: [{
          hex: "#141cbc"
        }, {
          hex: "#848cd4"
        }]
      }
    },
    "discord.com:trial": {
      html: '<div style="font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Helvetica, Arial, sans-serif; max-width: 560px; margin: 0 auto; background: #ffffff; color: #18181b;">\n  <!-- Top color strip -->\n  <div style="height: 6px; background: #141cbc; border-radius: 0;"></div>\n\n  <!-- Main card -->\n  <div style="padding: 36px 40px 28px 40px;">\n\n    <!-- Logo area -->\n    <table role="presentation" cellpadding="0" cellspacing="0" style="margin-bottom: 32px;">\n      <tr>\n        <td style="padding-right: 12px;"><img src="https://media.brand.dev/2aa75795-9fbe-417f-897e-f91467a6d676.jpg" alt="Discord icon" width="36" height="36" style="display: block; border-radius: 8px;" /></td>\n        <td style="font-size: 20px; font-weight: 700; color: #141cbc; letter-spacing: -0.3px; vertical-align: middle;">Discord</td>\n      </tr>\n    </table>\n\n    <!-- Headline -->\n    <h1 style="margin: 0 0 20px 0; font-size: 28px; font-weight: 800; color: #141cbc; line-height: 1.25; letter-spacing: -0.5px;">\n      Your Nitro trial ends in 2 days. ⏰\n    </h1>\n\n    <!-- Body copy -->\n    <p style="margin: 0 0 16px 0; font-size: 16px; line-height: 1.6; color: #3f3f46;">\n      Heads up: when the trial ends on Saturday, your animated avatar goes still, uploads drop back to 8MB, HD streaming switches off, and the two server boosts you placed will be removed from your communities.\n    </p>\n    <p style="margin: 0 0 32px 0; font-size: 16px; line-height: 1.6; color: #3f3f46;">\n      Keep Nitro and nothing changes — same perks, same boosts, same custom emoji everywhere. Lock it in before Saturday and your boosted servers never notice a thing.\n    </p>\n\n    <!-- CTA Button -->\n    <a href="https://discord.com" style="display: inline-block; background: #141cbc; color: #ffffff; text-decoration: none; font-size: 15px; font-weight: 700; padding: 14px 32px; border-radius: 8px; letter-spacing: 0.2px;">\n      Keep Nitro →\n    </a>\n\n  </div>\n\n  <!-- Footer divider -->\n  <div style="height: 2px; background: #848cd4; margin: 0 40px; border-radius: 2px; opacity: 0.35;"></div>\n\n  <!-- Footer -->\n  <table role="presentation" width="100%" cellpadding="0" cellspacing="0">\n    <tr>\n      <td align="left" style="padding: 20px 0 28px 40px; font-size: 12px; color: #848cd4; font-weight: 600; letter-spacing: 0.3px;">discord.com</td>\n      <td align="right" style="padding: 20px 40px 28px 0; font-size: 12px; color: #a1a1aa;">© 2024 Discord Inc. All rights reserved.</td>\n    </tr>\n  </table>\n</div>',
      brand: {
        title: "Discord",
        domain: "discord.com",
        colors: [{
          hex: "#141cbc"
        }, {
          hex: "#848cd4"
        }]
      }
    }
  };
  const [domain, setDomain] = useState(DEFAULT_DOMAIN);
  const [kind, setKind] = useState(DEFAULT_KIND);
  const [html, setHtml] = useState(DEFAULT_HTML);
  const [brand, setBrand] = useState(DEFAULT_BRAND);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const isDefault = html === DEFAULT_HTML && domain === DEFAULT_DOMAIN && kind === DEFAULT_KIND;
  async function select(d, k) {
    if (d === domain && k === kind || loading) return;
    setDomain(d);
    setKind(k);
    setError(null);
    setLoading(true);
    await new Promise(r => setTimeout(r, 900));
    const hit = STATIC[`${d}:${k}`];
    setHtml(hit.html);
    setBrand(hit.brand);
    setLoading(false);
  }
  function reset() {
    setDomain(DEFAULT_DOMAIN);
    setKind(DEFAULT_KIND);
    setHtml(DEFAULT_HTML);
    setBrand(DEFAULT_BRAND);
    setError(null);
  }
  const primary = brand && brand.colors && brand.colors[0] && brand.colors[0].hex || null;
  const secondary = brand && brand.colors && brand.colors[1] && brand.colors[1].hex || null;
  const cleanDomain = brand && brand.domain || domain;
  const name = brand && brand.title || cleanDomain;
  const currentKind = KINDS.find(k => k.id === kind) || KINDS[0];
  const srcDoc = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body style="margin:0;padding:28px;background:#fafafa;">${html}</body></html>`;
  return <div className="et-demo not-prose">
      <style>{`
        .et-demo {
          --et-blue: #1373e8;
          --et-blue-press: #0e5cc0;
          --et-pill-bd: #cfd9e8;
          --et-line: #ececec;
          --et-line-soft: #f4f4f5;
          --et-ink: #0a0a0a;
          --et-ink-2: #3f3f46;
          --et-ink-3: #919191;
          --et-bg: #ffffff;
          --et-bg-soft: #fbfcfe;
          --et-sans: "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
          --et-mono: "Geist Mono", "IBM Plex Mono", ui-monospace, Menlo, monospace;
          position: relative;
          margin: 28px 0;
          padding: 22px 22px 20px;
          background: var(--et-bg);
          border: 1px solid var(--et-line);
          border-radius: 6px;
          font-family: var(--et-sans);
          color: var(--et-ink);
          overflow: hidden;
        }
        .et-demo::before {
          content: ""; position: absolute; inset: 0; pointer-events: none;
          background: radial-gradient(800px 280px at 50% 100%, rgba(96, 165, 250, 0.10), transparent 70%);
        }
        .et-demo > * { position: relative; }
        .et-demo *, .et-demo *::before, .et-demo *::after { box-sizing: border-box; }

        .dark .et-demo {
          --et-blue: #3b82f6;
          --et-blue-press: #2563eb;
          --et-pill-bd: #2f3a4d;
          --et-line: #26262d;
          --et-line-soft: #1a1a1f;
          --et-ink: #f5f5f7;
          --et-ink-2: #c2c2cb;
          --et-ink-3: #8b8b93;
          --et-bg: #0a0a0c;
          --et-bg-soft: #141418;
        }
        .dark .et-error { color: #fca5a5; background: #2a1416; border-color: #5b2526; }
        .dark .et-overlay { background: rgba(10, 10, 12, 0.85); }

        .et-chrome {
          display: flex; align-items: center; justify-content: space-between;
          margin-bottom: 18px; min-height: 24px;
        }
        .et-kicker {
          display: inline-flex; align-items: center; gap: 9px;
          font-family: var(--et-mono);
          font-size: 11px; font-weight: 500;
          letter-spacing: 0.04em; text-transform: uppercase;
          color: var(--et-ink-3);
        }
        .et-kicker::before {
          content: ""; width: 5px; height: 5px;
          background: var(--et-blue); border-radius: 1px;
        }
        .et-logo-light, .et-logo-dark { height: 22px; display: block; }
        .et-logo-dark { display: none; }
        .dark .et-logo-light { display: none; }
        .dark .et-logo-dark { display: block; }
        .et-reset {
          font-family: var(--et-mono);
          font-size: 10.5px; letter-spacing: 0.10em;
          text-transform: uppercase; color: var(--et-ink-2);
          background: var(--et-bg);
          border: 1px solid var(--et-line);
          border-radius: 0;
          padding: 5px 10px;
          cursor: pointer;
          transition: color 160ms ease, border-color 160ms ease;
        }
        .et-reset:hover { color: var(--et-blue); border-color: var(--et-blue); }

        .et-form { display: flex; flex-direction: column; gap: 14px; margin-bottom: 14px; }
        .et-field { display: flex; flex-direction: column; }
        .et-label {
          font-family: var(--et-mono);
          font-size: 10.5px; letter-spacing: 0.10em;
          text-transform: uppercase; color: var(--et-ink-3);
          margin-bottom: 8px;
        }

        .et-row { display: flex; gap: 8px; }
        .et-input {
          flex: 1;
          padding: 11px 13px;
          background: var(--et-bg);
          border: 1px solid var(--et-line);
          border-radius: 0;
          font-family: var(--et-mono);
          font-size: 14px;
          color: var(--et-ink);
          outline: none;
          min-width: 0;
        }
        .et-input:focus { border-color: var(--et-blue); }
        .et-btn {
          padding: 11px 18px;
          background: var(--et-blue);
          color: #fff;
          border: 1px solid var(--et-blue);
          border-radius: 0;
          font-family: var(--et-mono);
          font-size: 13px;
          font-weight: 500;
          cursor: pointer;
          white-space: nowrap;
        }
        .et-btn:hover { background: var(--et-blue-press); }
        .et-btn:disabled { opacity: 0.55; cursor: progress; }

        .et-seg {
          display: inline-flex;
          padding: 4px;
          border: 1px solid var(--et-line);
          background: var(--et-bg);
          align-self: flex-start;
          gap: 4px;
        }
        .et-seg-btn {
          font-family: var(--et-mono);
          font-size: 12px;
          padding: 7px 14px;
          background: transparent;
          color: var(--et-ink-2);
          border: 0;
          cursor: pointer;
        }
        .et-seg-btn:hover { color: var(--et-blue); }
        .et-seg-btn.active { background: var(--et-blue); color: #fff; }

        .et-trys {
          display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap;
          font-family: var(--et-mono);
          font-size: 11px;
          color: var(--et-ink-3);
        }
        .et-try {
          font-family: var(--et-mono);
          font-size: 11px;
          padding: 2px 7px;
          background: var(--et-bg);
          border: 1px solid var(--et-line);
          color: var(--et-ink-2);
          cursor: pointer;
        }
        .et-try:hover { color: var(--et-blue); border-color: var(--et-blue); }

        .et-error {
          padding: 8px 11px; margin-bottom: 10px;
          font-family: var(--et-mono);
          font-size: 11.5px;
          color: #9b1c1c; background: #fbf0f0;
          border: 1px solid #e6c5c5;
        }
        .et-divider { height: 1px; margin: 22px 0; background: var(--et-line); border: 0; }

        .et-result {
          position: relative;
          background: var(--et-bg-soft);
          border: 1px solid var(--et-line);
          overflow: hidden;
        }
        .et-result-meta {
          padding: 11px 14px;
          border-bottom: 1px solid var(--et-line);
          display: flex; align-items: center; justify-content: space-between;
          flex-wrap: wrap; gap: 8px;
        }
        .et-result-meta .who {
          font-family: var(--et-mono);
          font-size: 11.5px;
          color: var(--et-ink);
        }
        .et-result-meta .who strong { font-weight: 500; }
        .et-result-meta .who .sub { color: var(--et-ink-3); }
        .et-swatches { display: flex; align-items: center; gap: 6px; }
        .et-sw { width: 14px; height: 14px; border: 1px solid rgba(0,0,0,0.08); }
        .et-sw-text {
          font-family: var(--et-mono);
          font-size: 10.5px;
          color: var(--et-ink-3);
        }
        .et-frame {
          width: 100%;
          height: 580px;
          border: 0;
          display: block;
          background: #fafafa;
        }
        .et-overlay {
          position: absolute; inset: 0;
          background: rgba(255, 255, 255, 0.85);
          backdrop-filter: blur(2px);
          display: flex; flex-direction: column;
          align-items: center; justify-content: center;
          gap: 12px;
          z-index: 5;
        }
        .et-spin {
          width: 22px; height: 22px; border-radius: 50%;
          border: 1.5px solid var(--et-line);
          border-top-color: var(--et-blue);
          animation: et-spin 0.7s linear infinite;
        }
        @keyframes et-spin { to { transform: rotate(360deg); } }
        .et-overlay .et-line {
          font-family: var(--et-mono);
          font-size: 10.5px; letter-spacing: 0.14em;
          text-transform: uppercase; color: var(--et-ink-3);
        }
      `}</style>

      <div className="et-chrome">
        <span className="et-kicker">Live demo</span>
        {isDefault ? <span className="et-logo">
            <img className="et-logo-light" src="/logo/light.svg" alt="context.dev" />
            <img className="et-logo-dark" src="/logo/dark.svg" alt="context.dev" />
          </span> : <button className="et-reset" type="button" onClick={reset}>
            Reset
          </button>}
      </div>

      <form className="et-form" onSubmit={e => e.preventDefault()}>
        <div className="et-field">
          <label className="et-label" htmlFor="et-domain-input">
            Enter a domain
          </label>
          <div className="et-row">
            <input id="et-domain-input" className="et-input" type="text" value={domain} readOnly placeholder="example.com" spellCheck={false} required />
          </div>
          <div className="et-trys" style={{
    marginTop: 10
  }}>
            <span>Change demo</span>
            {TRY.map(d => <button key={d} type="button" className="et-try" onClick={() => select(d, kind)}>
                {d}
              </button>)}
          </div>
        </div>

        <div className="et-field">
          <label className="et-label">Choose a type</label>
          <div className="et-seg" role="tablist">
            {KINDS.map(k => <button key={k.id} type="button" role="tab" aria-selected={kind === k.id} className={`et-seg-btn${kind === k.id ? " active" : ""}`} onClick={() => select(domain, k.id)}>
                {k.label}
              </button>)}
          </div>
        </div>
      </form>

      {error && <div className="et-error">{error}</div>}

      <div className="et-divider" />

      <div className="et-result">
        <div className="et-result-meta">
          <span className="who">
            <strong>{currentKind.label}</strong>
            <span className="sub"> · {name}</span>
          </span>
          <div className="et-swatches">
            {primary && <>
                <span className="et-sw" style={{
    background: primary
  }} />
                <span className="et-sw-text">{primary}</span>
              </>}
            {secondary && <>
                <span className="et-sw" style={{
    background: secondary,
    marginLeft: 4
  }} />
                <span className="et-sw-text">{secondary}</span>
              </>}
          </div>
        </div>
        <div style={{
    position: "relative"
  }}>
          <iframe className="et-frame" sandbox="allow-same-origin" srcDoc={srcDoc} title={`${currentKind.label} email for ${name}`} />
          {loading && <div className="et-overlay">
              <div className="et-spin" />
              <div className="et-line">Brand · Styleguide · Generate</div>
            </div>}
        </div>
      </div>
    </div>;
};

export const PromptBanner = ({prompt, message = "Use this pre-built prompt to get started faster."}) => {
  const [copied, setCopied] = useState(false);
  const timeoutRef = useRef(null);
  useEffect(() => {
    return () => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }
    };
  }, []);
  const handleCopy = useCallback(async () => {
    try {
      await navigator.clipboard.writeText(prompt);
      setCopied(true);
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }
      timeoutRef.current = setTimeout(() => {
        setCopied(false);
        timeoutRef.current = null;
      }, 2000);
    } catch (err) {
      console.error("Failed to copy:", err);
    }
  }, [prompt]);
  const handleOpenInCursor = useCallback(() => {
    const url = new URL('cursor://anysphere.cursor-deeplink/prompt');
    url.searchParams.set('text', prompt);
    window.location.href = url.toString();
  }, [prompt]);
  return <div className="not-prose my-6 rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 overflow-hidden">
      <div className="p-4">
        <div className="mb-3">
          <div className="text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-2">
            {message}
          </div>
          <div className="relative">
            <textarea readOnly value={prompt} className="w-full px-3 py-2 text-sm font-mono text-zinc-700 dark:text-zinc-300 bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-md resize-none focus:outline-none focus:ring-2 focus:ring-zinc-400 dark:focus:ring-zinc-600" rows={Math.min(prompt.split('\n').length, 6)} />
          </div>
        </div>

        <div className="flex items-center gap-2">
          <button onClick={handleOpenInCursor} className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-zinc-950 dark:bg-white text-sm font-medium text-white dark:text-zinc-950 hover:bg-zinc-800 dark:hover:bg-zinc-200 transition-colors" aria-label="Try in Cursor">
            <img src="/images/cursor-light.svg" alt="Cursor logo" className="size-4 block dark:hidden" loading="lazy" width="16" height="16" />
            <img src="/images/cursor-dark.svg" alt="Cursor logo" className="size-4 hidden dark:block" loading="lazy" width="16" height="16" />
            Try in Cursor
          </button>

          <button onClick={handleCopy} className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-zinc-100 dark:bg-zinc-800 text-sm font-medium text-zinc-950 dark:text-white hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors" aria-label={copied ? "Copied" : "Copy prompt"} aria-live="polite">
            {copied ? <>
                <svg className="size-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
                </svg>
                Copied
              </> : <>
                <svg className="size-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
                </svg>
                Copy prompt
              </>}
          </button>
        </div>
      </div>
    </div>;
};

If you use coding agents for email template design, you know they often produce generic designs that do not match your brand.

To get on-theme designs, you need to provide your agents with **"Design Tokens"** of your brand: logos, color schemes, typography, shadows, spacing, background images, component screenshots and more.

That's exactly what Context.dev's [Brand API](/guides/get-brand-data), [Styleguide API](/guides/extract-design-system-from-website#extract-the-full-styleguide), [Screenshot API](/guides/take-webpage-screenshot) and [Image Scraping API](/guides/scrape-websites-to-markdown#extract-every-image-on-a-page) give you.

In this guide, we'll use these APIs to build a fully-automated brand-themed email template generation pipeline — highly useful for products that let users send emails.

Try it:

<EmailTemplateDemo />

<PromptBanner
  message="Drop this prompt into your AI coding tool to build the branded-email pipeline."
  prompt="Implement a pipeline that turns a domain into an on-brand HTML email using Context.dev and the Anthropic API. This is an engineering task, not a design task: the actual design happens inside one LLM call (step 3), so wire up the pipeline rather than styling anything yourself.

1. Read CONTEXT_DEV_API_KEY and ANTHROPIC_API_KEY from env. Never call Context.dev from the client-side browser.

2. Gather homepage context in parallel: /brand/retrieve (10 credits) for logos, backdrops, and brand.links; /web/styleguide (10 credits) for the design tokens (colors, typography, spacing, shadows, components); /web/screenshot (5 credits) for the homepage; and /web/scrape/images (1 credit) for the homepage image manifest, keeping the CSS background-image URLs. Screenshots come back as CDN URLs.

3. Generate: call a vision-capable model (Claude Opus 4.8) with a system prompt that holds the email-rendering rules, passing the brand JSON, the styleguide JSON, the background-image URLs as text, and the homepage screenshot as an image input. The model returns one self-contained HTML document. Keep the rendering rules in that system prompt, not in your code: inline all CSS (Outlook strips style blocks), table role=presentation layout (no flexbox or grid), brand font first in a web-safe stack, explicit img widths and alt text, single column under 600px and 102KB.

4. Test: lint the HTML for size, script tags, remote CSS, flexbox/grid, and missing img widths or alt text, then render it across clients with a preview service like Litmus or Email on Acid. If the lint fails or a preview looks broken, go back to step 3 and regenerate.

Full guide: https://docs.context.dev/use-cases/custom-email-templates. Never expose CONTEXT_DEV_API_KEY to the browser."
/>

## Architecture

The pipeline runs in three phases: gather, generate, and test.

1. The **Gather** step takes in a domain name and collects context about the brand and "design tokens" to set the LLM up to generate relevant and consistent designs.

2. The **Generate** step calls an agent to generate the actual email template in HTML and inline CSS.

3. The **Test** phase is just a bunch of pre-flight checks to ensure the email template will render correctly in the end user's inbox.

## Prerequisites

* **A Context.dev API key.** Grab one from the [dashboard](https://context.dev/dashboard) and export it as `CONTEXT_DEV_API_KEY`.
* **An Anthropic API key** for the generation step. Grab one from the [Anthropic Console](https://console.anthropic.com/settings/keys), export it as `ANTHROPIC_API_KEY`, and install the Anthropic SDK (`@anthropic-ai/sdk`, `anthropic`, or `anthropic-sdk-go`).
* **The Context.dev SDK** for your backend:

<CodeGroup>
  ```bash TypeScript theme={null}
  npm install context.dev
  ```

  ```bash Python theme={null}
  pip install context.dev
  ```

  ```bash Ruby theme={null}
  gem install context.dev
  ```

  ```bash Go theme={null}
  go get github.com/context-dot-dev/context-go-sdk
  ```

  ```bash PHP theme={null}
  composer require context-dev/context-dev-php
  ```
</CodeGroup>

## Step 1. Gather the brand context

These four APIs have all the context our agent needs:

1. **Brand API**: the brand profile.
   * `logos[]`: logo and icon variants, each with `url`, `type`, and `mode`
   * `backdrops[]`: hero and background imagery
   * `links`: standard page URLs (`pricing`, `blog`, `login`, `signup`, `careers`, `contact`, `privacy`, `terms`)
   * `title`, `description`, `slogan`, `socials[]`, `address`, and `industries`
2. **Styleguide API**: the homepage's design tokens.
   * `mode`: `light` or `dark`
   * `colors`: `accent`, `background`, and `text`
   * `typography`: `headings.h1` to `h4` and `p`, each with `fontFamily`, `fontFallbacks`, `fontSize`, `fontWeight`, and `lineHeight`
   * `elementSpacing`: an `xs` to `xl` spacing scale
   * `shadows`: `sm` to `xl` plus `inner` box-shadow values
   * `components`: `button` and `card`, each with ready-to-paste `css`
   * `fontLinks`: downloadable font files keyed by family
3. **Screenshot API**: a hosted render of each page.
   * `screenshot`: a CDN URL for the captured PNG
   * `screenshotType`: `viewport` or `fullPage`
   * `width` and `height`: the captured dimensions
4. **Image Scraping API**: each page's image manifest.
   * `images[]`: every image on the page, each with `src`, `element` (`img`, `svg`, `css`, `background`, and more), `type`, and `alt`
   * `images[].enrichment` (optional): `width`, `height`, `hostedUrl`, and `classification`

This guide gathers the **homepage only**. Brand API also returns `brand.links` (pricing, blog, login, and the rest of the standard pages); running the screenshot and image scrape on a few of those too is useful extra context for the model, but optional.

<CodeGroup>
  ```typescript TypeScript theme={null}
  import ContextDev from "context.dev";

  const client = new ContextDev({ apiKey: process.env.CONTEXT_DEV_API_KEY });

  export async function gatherBrandContext(domain: string) {
    const homepage = `https://${domain}`;

    // Brand, design tokens, a screenshot, and an image manifest, all for the homepage.
    const [brandRes, sgRes, shot, imgs] = await Promise.all([
      client.brand.retrieve({ type: "by_domain", domain }),
      client.web.extractStyleguide({ domain }),
      client.web.screenshot({ directUrl: homepage, handleCookiePopup: "true" }),
      client.web.webScrapeImages({ url: homepage }),
    ]);

    const brand = brandRes.brand;
    const logos = brand?.logos ?? [];
    const lightLogo =
      logos.find((l) => l.type === "logo" && l.mode === "light") ?? logos[0];
    // CSS background images a flat screenshot can't isolate.
    const backgrounds = (imgs.images ?? [])
      .filter((i) => i.element === "background" || i.element === "css")
      .map((i) => i.src);

    return {
      name: brand?.title ?? domain,
      logo: lightLogo?.url ?? null,
      colors: sgRes.styleguide.colors,
      styleguide: sgRes.styleguide,
      screenshot: shot.screenshot ?? "",
      backgrounds,
    };
  }
  ```

  ```python Python theme={null}
  import os
  from context.dev import ContextDev

  client = ContextDev(api_key=os.environ["CONTEXT_DEV_API_KEY"])


  def gather_brand_context(domain: str) -> dict:
      homepage = f"https://{domain}"

      # Brand, design tokens, a screenshot, and an image manifest, all for the homepage.
      brand = client.brand.retrieve(type="by_domain", domain=domain).brand
      styleguide = client.web.extract_styleguide(domain=domain).styleguide
      shot = client.web.screenshot(direct_url=homepage, handle_cookie_popup="true")
      imgs = client.web.web_scrape_images(url=homepage)

      logos = brand.logos or []
      light_logo = next(
          (l for l in logos if l.type == "logo" and l.mode == "light"),
          logos[0] if logos else None,
      )
      backgrounds = [i.src for i in (imgs.images or []) if i.element in ("background", "css")]

      return {
          "name": brand.title,
          "logo": light_logo.url if light_logo else None,
          "colors": styleguide.colors.to_dict(),
          "styleguide": styleguide.to_dict(),
          "screenshot": shot.screenshot,
          "backgrounds": backgrounds,
      }
  ```

  ```ruby Ruby theme={null}
  require "context_dev"

  CLIENT = ContextDev::Client.new(api_key: ENV.fetch("CONTEXT_DEV_API_KEY"))

  def gather_brand_context(domain)
    homepage = "https://#{domain}"

    # Brand, design tokens, a screenshot, and an image manifest, all for the homepage.
    brand = CLIENT.brand.retrieve(body: { type: :by_domain, domain: domain }).brand
    styleguide = CLIENT.web.extract_styleguide(domain: domain).styleguide
    shot = CLIENT.web.screenshot(direct_url: homepage, handle_cookie_popup: "true")
    imgs = CLIENT.web.web_scrape_images(url: homepage)

    logos = brand.logos || []
    light_logo = logos.find { |l| l.type.to_s == "logo" && l.mode.to_s == "light" } || logos.first
    # `element` comes back as a Symbol, so compare with to_s.
    backgrounds = imgs.images.select { |i| %w[background css].include?(i.element.to_s) }.map(&:src)

    {
      name: brand.title,
      logo: light_logo&.url,
      colors: styleguide.to_h[:colors],
      styleguide: styleguide.to_h,
      screenshot: shot.screenshot,
      backgrounds: backgrounds,
    }
  end
  ```

  ```go Go theme={null}
  package main

  import (
  	"context"
  	"os"

  	contextdev "github.com/context-dot-dev/context-go-sdk"
  	"github.com/context-dot-dev/context-go-sdk/option"
  	"github.com/context-dot-dev/context-go-sdk/packages/param"
  )

  var client = contextdev.NewClient(option.WithAPIKey(os.Getenv("CONTEXT_DEV_API_KEY")))

  type BrandContext struct {
  	Name, Logo, Screenshot string
  	Colors, Styleguide     any
  	Backgrounds            []string
  }

  func gatherBrandContext(ctx context.Context, domain string) (*BrandContext, error) {
  	home := "https://" + domain

  	// Brand, design tokens, a screenshot, and an image manifest, all for the homepage.
  	brand, err := client.Brand.Get(ctx, contextdev.BrandGetParams{OfByDomain: &contextdev.BrandGetParamsBodyByDomain{Domain: domain}})
  	if err != nil {
  		return nil, err
  	}
  	sg, err := client.Web.ExtractStyleguide(ctx, contextdev.WebExtractStyleguideParams{Domain: param.NewOpt(domain)})
  	if err != nil {
  		return nil, err
  	}
  	shot, err := client.Web.Screenshot(ctx, contextdev.WebScreenshotParams{
  		DirectURL:         param.NewOpt(home),
  		HandleCookiePopup: contextdev.WebScreenshotParamsHandleCookiePopupTrue,
  	})
  	if err != nil {
  		return nil, err
  	}
  	imgs, err := client.Web.WebScrapeImages(ctx, contextdev.WebWebScrapeImagesParams{URL: home})
  	if err != nil {
  		return nil, err
  	}

  	logo := ""
  	for _, l := range brand.Brand.Logos {
  		if l.Type == "logo" && l.Mode == "light" {
  			logo = l.URL
  			break
  		}
  	}
  	if logo == "" && len(brand.Brand.Logos) > 0 {
  		logo = brand.Brand.Logos[0].URL
  	}
  	var backgrounds []string
  	for _, i := range imgs.Images {
  		if i.Element == "background" || i.Element == "css" {
  			backgrounds = append(backgrounds, i.Src)
  		}
  	}

  	return &BrandContext{
  		Name:        brand.Brand.Title,
  		Logo:        logo,
  		Colors:      sg.Styleguide.Colors,
  		Styleguide:  sg.Styleguide,
  		Screenshot:  shot.Screenshot,
  		Backgrounds: backgrounds,
  	}, nil
  }
  ```

  ```php PHP theme={null}
  <?php

  use ContextDev\Client;

  $client = new Client(apiKey: getenv('CONTEXT_DEV_API_KEY'));

  function gatherBrandContext(string $domain): array
  {
      global $client;

      $homepage = "https://{$domain}";

      // Brand, design tokens, a screenshot, and an image manifest, all for the homepage.
      $brandRes = $client->brand->retrieve(type: 'by_domain', domain: $domain);
      $sgRes = $client->web->extractStyleguide(domain: $domain);
      $shot = $client->web->screenshot(directURL: $homepage, handleCookiePopup: 'true');
      $imgs = $client->web->webScrapeImages(url: $homepage);

      $brand = $brandRes->brand;
      $logos = $brand->logos ?? [];
      $lightLogo = null;
      foreach ($logos as $l) {
          if ($l->type === 'logo' && $l->mode === 'light') {
              $lightLogo = $l;
              break;
          }
      }
      $lightLogo ??= $logos[0] ?? null;

      // CSS background images a flat screenshot can't isolate.
      $backgrounds = [];
      foreach ($imgs->images ?? [] as $i) {
          if ($i->element === 'background' || $i->element === 'css') {
              $backgrounds[] = $i->src;
          }
      }

      return [
          'name' => $brand->title ?? $domain,
          'logo' => $lightLogo->url ?? null,
          'colors' => $sgRes->styleguide->colors,
          'styleguide' => $sgRes->styleguide,
          'screenshot' => $shot->screenshot ?? '',
          'backgrounds' => $backgrounds,
      ];
  }
  ```
</CodeGroup>

<Badge color="blue">Brand · 10 credits</Badge> <Badge color="blue">Styleguide · 10 credits</Badge> <Badge color="blue">Screenshot · 5 credits</Badge> <Badge color="blue">Image scrape · 1 credit</Badge>

That is about 26 credits per brand. It all caches, so you pay it once per brand and reuse the context for every email you generate.

## Step 2. Generate the template with an LLM

Now, we feed the gathered context into an LLM call.

We recommend **Claude Opus 4.8** for visual design tasks like these. But you can experiment with models as you like.

Here's the **system prompt** we'll be using. It includes a description of the schema of the design tokens we're providing and some email HTML/CSS rendering best practices. Save it as a file named `system-prompt.txt`.

<Prompt description="System prompt for the email generator" actions={["copy"]}>
  You are an expert HTML email designer. From a brand's colors, logo, typography tokens, page screenshots, and image manifest, produce ONE self-contained HTML email that looks like it came from that brand.

  Match the brand:

  * Use only the colors, fonts, logo, and images in the provided context. Never invent assets or pull in anything external.
  * Lead with the styleguide accent color and typeface, and mirror the spacing and visual rhythm of the screenshots.

  Render reliably in every client:

  * Output one HTML document with all CSS inlined. Do not rely on style blocks; Outlook strips most of them.
  * Lay out with nested tables using role=presentation. No flexbox, grid, float, or position.
  * Single column, 600px maximum width.
  * Set an explicit pixel width on every image, and give each one descriptive alt text.
  * Put the brand font first in font-family, then a web-safe fallback stack (Arial, Helvetica, sans-serif). Never depend on a remote @font-face for layout.
  * Build buttons as table cells with a background color and padding, not button elements or CSS shapes.
  * Keep the whole document under 102KB so Gmail does not clip it.
  * Design so it still reads with images disabled: real text for anything important, solid background colors behind hero images.

  Return only the HTML document, with no commentary or code fences.
</Prompt>

<CodeGroup>
  ```typescript TypeScript theme={null}
  import { readFileSync } from "node:fs";
  import Anthropic from "@anthropic-ai/sdk";
  import { gatherBrandContext } from "./gather";

  const anthropic = new Anthropic(); // reads ANTHROPIC_API_KEY
  const MODEL = "claude-opus-4-8";   // Opus 4.8 for quality; Sonnet 4.6 for cheaper drafts
  const SYSTEM_PROMPT = readFileSync("system-prompt.txt", "utf8");

  export async function generateEmailTemplate(domain: string, brief: string) {
    const ctx = await gatherBrandContext(domain);

    const message = await anthropic.messages.create({
      model: MODEL,
      max_tokens: 8000,
      system: SYSTEM_PROMPT,
      messages: [
        {
          role: "user",
          content: [
            { type: "text", text: `Brand: ${ctx.name}. Build: ${brief}.` },
            { type: "text", text: `Logo: ${ctx.logo}\nColors: ${JSON.stringify(ctx.colors)}` },
            { type: "text", text: `Design tokens (styleguide):\n${JSON.stringify(ctx.styleguide)}` },
            { type: "text", text: `Background images: ${JSON.stringify(ctx.backgrounds)}` },
            { type: "image", source: { type: "url", url: ctx.screenshot } },
          ],
        },
      ],
    });

    return message.content.find((b) => b.type === "text")?.text ?? "";
  }
  ```

  ```python Python theme={null}
  import json
  from anthropic import Anthropic
  from gather import gather_brand_context

  anthropic = Anthropic()  # reads ANTHROPIC_API_KEY
  MODEL = "claude-opus-4-8"  # Opus 4.8 for quality; Sonnet 4.6 for cheaper drafts

  with open("system-prompt.txt") as f:
      SYSTEM_PROMPT = f.read()


  def generate_email_template(domain: str, brief: str) -> str:
      ctx = gather_brand_context(domain)

      message = anthropic.messages.create(
          model=MODEL,
          max_tokens=8000,
          system=SYSTEM_PROMPT,
          messages=[{
              "role": "user",
              "content": [
                  {"type": "text", "text": f"Brand: {ctx['name']}. Build: {brief}."},
                  {"type": "text", "text": f"Logo: {ctx['logo']}\nColors: {json.dumps(ctx['colors'])}"},
                  {"type": "text", "text": f"Design tokens (styleguide):\n{json.dumps(ctx['styleguide'])}"},
                  {"type": "text", "text": f"Background images: {json.dumps(ctx['backgrounds'])}"},
                  {"type": "image", "source": {"type": "url", "url": ctx["screenshot"]}},
              ],
          }],
      )
      return next((b.text for b in message.content if b.type == "text"), "")
  ```

  ```ruby Ruby theme={null}
  require "json"
  require "anthropic"
  require_relative "gather"

  ANTHROPIC = Anthropic::Client.new # reads ANTHROPIC_API_KEY; CLIENT is already the ContextDev client from gather.rb
  MODEL = "claude-opus-4-8"         # Opus 4.8 for quality; Sonnet 4.6 for cheaper drafts
  SYSTEM_PROMPT = File.read("system-prompt.txt")

  def generate_email_template(domain, brief)
    ctx = gather_brand_context(domain)

    message = ANTHROPIC.messages.create(
      model: MODEL,
      max_tokens: 8000,
      system: SYSTEM_PROMPT,
      messages: [{
        role: "user",
        content: [
          { type: "text", text: "Brand: #{ctx[:name]}. Build: #{brief}." },
          { type: "text", text: "Logo: #{ctx[:logo]}\nColors: #{ctx[:colors].to_json}" },
          { type: "text", text: "Design tokens (styleguide):\n#{ctx[:styleguide].to_json}" },
          { type: "text", text: "Background images: #{ctx[:backgrounds].to_json}" },
          { type: "image", source: { type: "url", url: ctx[:screenshot] } },
        ],
      }]
    )

    message.content.find { |b| b.type.to_s == "text" }&.text.to_s
  end
  ```

  ```go Go theme={null}
  package main

  import (
  	"context"
  	"encoding/json"
  	"fmt"
  	"os"

  	"github.com/anthropics/anthropic-sdk-go"
  	"github.com/anthropics/anthropic-sdk-go/option"
  )

  const model = "claude-opus-4-8" // Opus 4.8 for quality; Sonnet 4.6 for cheaper drafts

  func generateEmailTemplate(ctx context.Context, domain, brief string) (string, error) {
  	bc, err := gatherBrandContext(ctx, domain) // BrandContext from Step 1
  	if err != nil {
  		return "", err
  	}
  	systemPrompt, err := os.ReadFile("system-prompt.txt")
  	if err != nil {
  		return "", err
  	}
  	ai := anthropic.NewClient(option.WithAPIKey(os.Getenv("ANTHROPIC_API_KEY")))

  	colors, _ := json.Marshal(bc.Colors)
  	styleguide, _ := json.Marshal(bc.Styleguide)
  	backgrounds, _ := json.Marshal(bc.Backgrounds)

  	msg, err := ai.Messages.New(ctx, anthropic.MessageNewParams{
  		Model:     anthropic.Model(model),
  		MaxTokens: 8000,
  		System:    []anthropic.TextBlockParam{{Text: string(systemPrompt)}},
  		Messages: []anthropic.MessageParam{anthropic.NewUserMessage(
  			anthropic.NewTextBlock(fmt.Sprintf("Brand: %s. Build: %s.", bc.Name, brief)),
  			anthropic.NewTextBlock(fmt.Sprintf("Logo: %s\nColors: %s", bc.Logo, colors)),
  			anthropic.NewTextBlock(fmt.Sprintf("Design tokens (styleguide):\n%s", styleguide)),
  			anthropic.NewTextBlock(fmt.Sprintf("Background images: %s", backgrounds)),
  			anthropic.NewImageBlock(anthropic.URLImageSourceParam{URL: bc.Screenshot}),
  		)},
  	})
  	if err != nil {
  		return "", err
  	}

  	for _, block := range msg.Content {
  		if block.Type == "text" {
  			return block.Text, nil
  		}
  	}
  	return "", nil
  }
  ```

  ```php PHP theme={null}
  <?php
  // generate.php
  require_once 'gather.php';

  const MODEL = 'claude-opus-4-8'; // Opus 4.8 for quality; Sonnet 4.6 for cheaper drafts
  $SYSTEM_PROMPT = file_get_contents('system-prompt.txt');

  function generateEmailTemplate(string $domain, string $brief): string
  {
      $ctx = gatherBrandContext($domain);

      $payload = json_encode([
          'model' => MODEL,
          'max_tokens' => 8000,
          'system' => $SYSTEM_PROMPT,
          'messages' => [[
              'role' => 'user',
              'content' => [
                  ['type' => 'text', 'text' => "Brand: {$ctx['name']}. Build: {$brief}."],
                  ['type' => 'text', 'text' => 'Logo: ' . $ctx['logo'] . "\nColors: " . json_encode($ctx['colors'])],
                  ['type' => 'text', 'text' => "Design tokens (styleguide):\n" . json_encode($ctx['styleguide'])],
                  ['type' => 'text', 'text' => 'Background images: ' . json_encode($ctx['backgrounds'])],
                  ['type' => 'image', 'source' => ['type' => 'url', 'url' => $ctx['screenshot']]],
              ],
          ]],
      ]);

      $ch = curl_init('https://api.anthropic.com/v1/messages');
      curl_setopt_array($ch, [
          CURLOPT_POST => true,
          CURLOPT_RETURNTRANSFER => true,
          CURLOPT_HTTPHEADER => [
              'Content-Type: application/json',
              'x-api-key: ' . getenv('ANTHROPIC_API_KEY'),
              'anthropic-version: 2023-06-01',
          ],
          CURLOPT_POSTFIELDS => $payload,
      ]);
      $response = json_decode(curl_exec($ch), true);

      foreach ($response['content'] ?? [] as $block) {
          if (($block['type'] ?? '') === 'text') {
              return $block['text'];
          }
      }

      return '';
  }
  ```
</CodeGroup>

## Step 3. Test the rendered email

Email HTML/CSS is slightly harder to get right. That makes it very important to test before you send.

This pipeline runs two tests:

1. **Lints** to find email-client compatibility issues (oversized HTML, script tags, flexbox/grid, missing image attributes)
2. **Rendered Preview** to find aesthetic inconsistencies

### Lint the code

Catch issues in code:

<CodeGroup>
  ```typescript TypeScript theme={null}
  // Fast pre-flight. The real check is the cross-client render below.
  export function lintEmailHtml(html: string): string[] {
    const problems: string[] = [];
    const kb = Buffer.byteLength(html, "utf8") / 1024;

    if (kb > 102) problems.push(`HTML is ${kb.toFixed(0)}KB; Gmail clips above ~102KB.`);
    if (/<script/i.test(html)) problems.push("Contains a script tag, which every client strips.");
    if (/<link[^>]+stylesheet/i.test(html)) problems.push("Links remote CSS; inline it instead.");
    if (/display\s*:\s*(flex|grid)/i.test(html)) problems.push("Uses flexbox/grid; unreliable in Outlook.");

    for (const img of html.match(/<img\b[^>]*>/gi) ?? []) {
      if (!/\bwidth=/i.test(img)) problems.push("An img is missing an explicit width.");
      if (!/\balt=/i.test(img)) problems.push("An img is missing alt text.");
    }
    return problems;
  }
  ```

  ```python Python theme={null}
  import re


  def lint_email_html(html: str) -> list[str]:
      problems: list[str] = []
      kb = len(html.encode("utf-8")) / 1024

      if kb > 102:
          problems.append(f"HTML is {kb:.0f}KB; Gmail clips above ~102KB.")
      if re.search(r"<script", html, re.I):
          problems.append("Contains a script tag, which every client strips.")
      if re.search(r"<link[^>]+stylesheet", html, re.I):
          problems.append("Links remote CSS; inline it instead.")
      if re.search(r"display\s*:\s*(flex|grid)", html, re.I):
          problems.append("Uses flexbox/grid; unreliable in Outlook.")

      for img in re.findall(r"<img\b[^>]*>", html, re.I):
          if not re.search(r"\bwidth=", img, re.I):
              problems.append("An img is missing an explicit width.")
          if not re.search(r"\balt=", img, re.I):
              problems.append("An img is missing alt text.")
      return problems
  ```

  ```ruby Ruby theme={null}
  def lint_email_html(html)
    problems = []
    kb = html.bytesize / 1024.0

    problems << format("HTML is %dKB; Gmail clips above ~102KB.", kb) if kb > 102
    problems << "Contains a script tag, which every client strips." if html =~ /<script/i
    problems << "Links remote CSS; inline it instead." if html =~ /<link[^>]+stylesheet/i
    problems << "Uses flexbox/grid; unreliable in Outlook." if html =~ /display\s*:\s*(flex|grid)/i

    html.scan(/<img\b[^>]*>/i).each do |img|
      problems << "An img is missing an explicit width." unless img =~ /\bwidth=/i
      problems << "An img is missing alt text." unless img =~ /\balt=/i
    end
    problems
  end
  ```

  ```go Go theme={null}
  package main

  import (
  	"fmt"
  	"regexp"
  )

  var (
  	reScript = regexp.MustCompile(`(?i)<script`)
  	reLink   = regexp.MustCompile(`(?i)<link[^>]+stylesheet`)
  	reLayout = regexp.MustCompile(`(?i)display\s*:\s*(flex|grid)`)
  	reImg    = regexp.MustCompile(`(?i)<img\b[^>]*>`)
  	reWidth  = regexp.MustCompile(`(?i)\bwidth=`)
  	reAlt    = regexp.MustCompile(`(?i)\balt=`)
  )

  func lintEmailHTML(html string) []string {
  	var problems []string

  	if kb := len(html) / 1024; kb > 102 {
  		problems = append(problems, fmt.Sprintf("HTML is %dKB; Gmail clips above ~102KB.", kb))
  	}
  	if reScript.MatchString(html) {
  		problems = append(problems, "Contains a script tag, which every client strips.")
  	}
  	if reLink.MatchString(html) {
  		problems = append(problems, "Links remote CSS; inline it instead.")
  	}
  	if reLayout.MatchString(html) {
  		problems = append(problems, "Uses flexbox/grid; unreliable in Outlook.")
  	}
  	for _, img := range reImg.FindAllString(html, -1) {
  		if !reWidth.MatchString(img) {
  			problems = append(problems, "An img is missing an explicit width.")
  		}
  		if !reAlt.MatchString(img) {
  			problems = append(problems, "An img is missing alt text.")
  		}
  	}
  	return problems
  }
  ```

  ```php PHP theme={null}
  <?php
  // Fast pre-flight. The real check is the cross-client render below.
  function lintEmailHtml(string $html): array
  {
      $problems = [];
      $kb = strlen($html) / 1024;

      if ($kb > 102) {
          $problems[] = sprintf('HTML is %dKB; Gmail clips above ~102KB.', (int) $kb);
      }
      if (preg_match('/<script/i', $html)) {
          $problems[] = 'Contains a script tag, which every client strips.';
      }
      if (preg_match('/<link[^>]+stylesheet/i', $html)) {
          $problems[] = 'Links remote CSS; inline it instead.';
      }
      if (preg_match('/display\s*:\s*(flex|grid)/i', $html)) {
          $problems[] = 'Uses flexbox/grid; unreliable in Outlook.';
      }

      preg_match_all('/<img\b[^>]*>/i', $html, $matches);
      foreach ($matches[0] ?? [] as $img) {
          if (!preg_match('/\bwidth=/i', $img)) {
              $problems[] = 'An img is missing an explicit width.';
          }
          if (!preg_match('/\balt=/i', $img)) {
              $problems[] = 'An img is missing alt text.';
          }
      }

      return $problems;
  }
  ```
</CodeGroup>

If this fails, you can give the errors to an LLM to fix.

### Render across real clients

The standard way to verify how an email actually looks is a cross-client preview service like [Litmus](https://docs.litmus.com/instant) or [Email on Acid](https://api.emailonacid.com/docs/latest).

These services take in the HTML and send back real screenshots of how your email looks on popular email clients across Desktop and Mobile screen sizes.

Litmus's Instant API takes the HTML and hands back an `email_guid` you then pull per-client screenshots from:

<CodeGroup>
  ```typescript TypeScript theme={null}
  // Submit the generated HTML for real cross-client screenshots.
  export async function renderPreviews(html: string): Promise<string> {
    const auth = Buffer.from(`${process.env.LITMUS_API_KEY}:`).toString("base64");

    const res = await fetch("https://instant-api.litmus.com/v1/emails", {
      method: "POST",
      headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}` },
      body: JSON.stringify({ html_text: html }),
    });

    // Then fetch a screenshot per client; see https://docs.litmus.com/instant.
    const { email_guid } = await res.json();
    return email_guid;
  }
  ```

  ```python Python theme={null}
  import os
  import requests


  def render_previews(html: str) -> str:
      res = requests.post(
          "https://instant-api.litmus.com/v1/emails",
          json={"html_text": html},
          auth=(os.environ["LITMUS_API_KEY"], ""),
      )
      # Then fetch a screenshot per client; see https://docs.litmus.com/instant.
      return res.json()["email_guid"]
  ```

  ```ruby Ruby theme={null}
  require "net/http"
  require "json"

  def render_previews(html)
    uri = URI("https://instant-api.litmus.com/v1/emails")
    req = Net::HTTP::Post.new(uri)
    req["Content-Type"] = "application/json"
    req.basic_auth(ENV.fetch("LITMUS_API_KEY"), "")
    req.body = { html_text: html }.to_json

    res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
    # Then fetch a screenshot per client; see https://docs.litmus.com/instant.
    JSON.parse(res.body)["email_guid"]
  end
  ```

  ```go Go theme={null}
  package main

  import (
  	"bytes"
  	"encoding/json"
  	"net/http"
  	"os"
  )

  func renderPreviews(html string) (string, error) {
  	body, _ := json.Marshal(map[string]string{"html_text": html})
  	req, _ := http.NewRequest("POST", "https://instant-api.litmus.com/v1/emails", bytes.NewReader(body))
  	req.Header.Set("Content-Type", "application/json")
  	req.SetBasicAuth(os.Getenv("LITMUS_API_KEY"), "")

  	res, err := http.DefaultClient.Do(req)
  	if err != nil {
  		return "", err
  	}
  	defer res.Body.Close()

  	// Then fetch a screenshot per client; see https://docs.litmus.com/instant.
  	var out struct {
  		EmailGUID string `json:"email_guid"`
  	}
  	json.NewDecoder(res.Body).Decode(&out)
  	return out.EmailGUID, nil
  }
  ```

  ```php PHP theme={null}
  <?php
  // Submit the generated HTML for real cross-client screenshots.
  function renderPreviews(string $html): string
  {
      $ch = curl_init('https://instant-api.litmus.com/v1/emails');
      curl_setopt_array($ch, [
          CURLOPT_POST => true,
          CURLOPT_RETURNTRANSFER => true,
          CURLOPT_USERPWD => getenv('LITMUS_API_KEY') . ':',
          CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
          CURLOPT_POSTFIELDS => json_encode(['html_text' => $html]),
      ]);
      $response = json_decode(curl_exec($ch), true);

      // Then fetch a screenshot per client; see https://docs.litmus.com/instant.
      return $response['email_guid'] ?? '';
  }
  ```
</CodeGroup>

Previews come back in about ten seconds. Give them to a visual LLM to judge or let the user manually review them. If something seems odd, hand the email back to an LLM to redo it.

## Full implementation

Here is the whole pipeline (gather, generate, lint) as one runnable script per language. Each one reads `CONTEXT_DEV_API_KEY` and `ANTHROPIC_API_KEY` from the environment, expects the [`system-prompt.txt`](#step-2-generate-the-template-with-an-llm) from Step 2 alongside it, takes a domain and a brief as arguments, and writes `email.html`.

<Note>
  Every Context.dev call below was run against the live API, the Anthropic request shape was verified, and each file was type-checked or compiled before publishing. Run with, e.g., `npx tsx branded-email.ts stripe.com "a welcome email"`.
</Note>

<CodeGroup>
  ```typescript branded-email.ts theme={null}
  import { readFileSync, writeFileSync } from "node:fs";
  import ContextDev from "context.dev";
  import Anthropic from "@anthropic-ai/sdk";

  const contextdev = new ContextDev({ apiKey: process.env.CONTEXT_DEV_API_KEY });
  const MODEL = "claude-opus-4-8"; // Opus 4.8 for quality; Sonnet 4.6 for cheaper drafts
  const SYSTEM_PROMPT = readFileSync(new URL("./system-prompt.txt", import.meta.url), "utf8");

  // 1. Gather the homepage's brand context.
  export async function gatherBrandContext(domain: string) {
    const homepage = `https://${domain}`;
    const [brandRes, sgRes, shot, imgs] = await Promise.all([
      contextdev.brand.retrieve({ type: "by_domain", domain }),
      contextdev.web.extractStyleguide({ domain }),
      contextdev.web.screenshot({ directUrl: homepage, handleCookiePopup: "true" }),
      contextdev.web.webScrapeImages({ url: homepage }),
    ]);

    const brand = brandRes.brand;
    const logos = brand?.logos ?? [];
    const lightLogo =
      logos.find((l) => l.type === "logo" && l.mode === "light") ?? logos[0];
    const backgrounds = (imgs.images ?? [])
      .filter((i) => i.element === "background" || i.element === "css")
      .map((i) => i.src);

    return {
      name: brand?.title ?? domain,
      logo: lightLogo?.url ?? null,
      colors: sgRes.styleguide.colors,
      styleguide: sgRes.styleguide,
      screenshot: shot.screenshot ?? "",
      backgrounds,
    };
  }

  // 2. Hand the context to Claude and get back a self-contained HTML email.
  export async function generateEmail(domain: string, brief: string): Promise<string> {
    const ctx = await gatherBrandContext(domain);
    const anthropic = new Anthropic(); // reads ANTHROPIC_API_KEY

    const message = await anthropic.messages.create({
      model: MODEL,
      max_tokens: 8000,
      system: SYSTEM_PROMPT,
      messages: [
        {
          role: "user",
          content: [
            { type: "text", text: `Brand: ${ctx.name}. Build: ${brief}.` },
            { type: "text", text: `Logo: ${ctx.logo}\nColors: ${JSON.stringify(ctx.colors)}` },
            { type: "text", text: `Design tokens (styleguide):\n${JSON.stringify(ctx.styleguide)}` },
            { type: "text", text: `Background images: ${JSON.stringify(ctx.backgrounds)}` },
            { type: "image", source: { type: "url", url: ctx.screenshot } },
          ],
        },
      ],
    });

    return message.content.find((b) => b.type === "text")?.text ?? "";
  }

  // 3. Lint the generated HTML for email-client gotchas.
  export function lintEmailHtml(html: string): string[] {
    const problems: string[] = [];
    const kb = Buffer.byteLength(html, "utf8") / 1024;

    if (kb > 102) problems.push(`HTML is ${kb.toFixed(0)}KB; Gmail clips above ~102KB.`);
    if (/<script/i.test(html)) problems.push("Contains a script tag, which every client strips.");
    if (/<link[^>]+stylesheet/i.test(html)) problems.push("Links remote CSS; inline it instead.");
    if (/display\s*:\s*(flex|grid)/i.test(html)) problems.push("Uses flexbox/grid; unreliable in Outlook.");
    for (const img of html.match(/<img\b[^>]*>/gi) ?? []) {
      if (!/\bwidth=/i.test(img)) problems.push("An img is missing an explicit width.");
      if (!/\balt=/i.test(img)) problems.push("An img is missing alt text.");
    }
    return problems;
  }

  const domain = process.argv[2] ?? "stripe.com";
  const brief = process.argv[3] ?? "a welcome email";
  const html = await generateEmail(domain, brief);
  writeFileSync("email.html", html);
  const problems = lintEmailHtml(html);
  console.log(problems.length ? `Lint issues: ${problems.join(" | ")}` : "Lint clean.");
  console.log("Wrote email.html");
  ```

  ```python branded_email.py theme={null}
  import json
  import os
  import re
  import sys

  from anthropic import Anthropic
  from context.dev import ContextDev

  contextdev = ContextDev(api_key=os.environ["CONTEXT_DEV_API_KEY"])
  MODEL = "claude-opus-4-8"  # Opus 4.8 for quality; Sonnet 4.6 for cheaper drafts
  SYSTEM_PROMPT = open(os.path.join(os.path.dirname(__file__), "system-prompt.txt")).read()


  # 1. Gather the homepage's brand context.
  def gather_brand_context(domain: str) -> dict:
      homepage = f"https://{domain}"
      brand = contextdev.brand.retrieve(type="by_domain", domain=domain).brand
      styleguide = contextdev.web.extract_styleguide(domain=domain).styleguide
      shot = contextdev.web.screenshot(direct_url=homepage, handle_cookie_popup="true")
      imgs = contextdev.web.web_scrape_images(url=homepage)

      logos = brand.logos or []
      light_logo = next(
          (l for l in logos if l.type == "logo" and l.mode == "light"),
          logos[0] if logos else None,
      )
      backgrounds = [i.src for i in (imgs.images or []) if i.element in ("background", "css")]

      return {
          "name": brand.title,
          "logo": light_logo.url if light_logo else None,
          "colors": styleguide.colors.to_dict(),
          "styleguide": styleguide.to_dict(),
          "screenshot": shot.screenshot,
          "backgrounds": backgrounds,
      }


  # 2. Hand the context to Claude and get back a self-contained HTML email.
  def generate_email(domain: str, brief: str) -> str:
      ctx = gather_brand_context(domain)
      anthropic = Anthropic()  # reads ANTHROPIC_API_KEY

      message = anthropic.messages.create(
          model=MODEL,
          max_tokens=8000,
          system=SYSTEM_PROMPT,
          messages=[{
              "role": "user",
              "content": [
                  {"type": "text", "text": f"Brand: {ctx['name']}. Build: {brief}."},
                  {"type": "text", "text": f"Logo: {ctx['logo']}\nColors: {json.dumps(ctx['colors'])}"},
                  {"type": "text", "text": f"Design tokens (styleguide):\n{json.dumps(ctx['styleguide'])}"},
                  {"type": "text", "text": f"Background images: {json.dumps(ctx['backgrounds'])}"},
                  {"type": "image", "source": {"type": "url", "url": ctx["screenshot"]}},
              ],
          }],
      )
      return next((b.text for b in message.content if b.type == "text"), "")


  # 3. Lint the generated HTML for email-client gotchas.
  def lint_email_html(html: str) -> list[str]:
      problems: list[str] = []
      kb = len(html.encode("utf-8")) / 1024

      if kb > 102:
          problems.append(f"HTML is {kb:.0f}KB; Gmail clips above ~102KB.")
      if re.search(r"<script", html, re.I):
          problems.append("Contains a script tag, which every client strips.")
      if re.search(r"<link[^>]+stylesheet", html, re.I):
          problems.append("Links remote CSS; inline it instead.")
      if re.search(r"display\s*:\s*(flex|grid)", html, re.I):
          problems.append("Uses flexbox/grid; unreliable in Outlook.")
      for img in re.findall(r"<img\b[^>]*>", html, re.I):
          if not re.search(r"\bwidth=", img, re.I):
              problems.append("An img is missing an explicit width.")
          if not re.search(r"\balt=", img, re.I):
              problems.append("An img is missing alt text.")
      return problems


  if __name__ == "__main__":
      domain = sys.argv[1] if len(sys.argv) > 1 else "stripe.com"
      brief = sys.argv[2] if len(sys.argv) > 2 else "a welcome email"
      html = generate_email(domain, brief)
      with open("email.html", "w") as f:
          f.write(html)
      issues = lint_email_html(html)
      print(f"Lint issues: {' | '.join(issues)}" if issues else "Lint clean.")
      print("Wrote email.html")
  ```

  ```ruby branded_email.rb theme={null}
  require "json"
  require "context_dev"
  require "anthropic"

  CONTEXTDEV = ContextDev::Client.new(api_key: ENV.fetch("CONTEXT_DEV_API_KEY"))
  MODEL = "claude-opus-4-8" # Opus 4.8 for quality; Sonnet 4.6 for cheaper drafts
  SYSTEM_PROMPT = File.read(File.join(__dir__, "system-prompt.txt"))

  # 1. Gather the homepage's brand context.
  def gather_brand_context(domain)
    homepage = "https://#{domain}"
    brand = CONTEXTDEV.brand.retrieve(body: { type: :by_domain, domain: domain }).brand
    styleguide = CONTEXTDEV.web.extract_styleguide(domain: domain).styleguide
    shot = CONTEXTDEV.web.screenshot(direct_url: homepage, handle_cookie_popup: "true")
    imgs = CONTEXTDEV.web.web_scrape_images(url: homepage)

    logos = brand.logos || []
    light_logo = logos.find { |l| l.type.to_s == "logo" && l.mode.to_s == "light" } || logos.first
    # `element` comes back as a Symbol, so compare with to_s.
    backgrounds = imgs.images.select { |i| %w[background css].include?(i.element.to_s) }.map(&:src)

    {
      name: brand.title,
      logo: light_logo&.url,
      colors: styleguide.to_h[:colors],
      styleguide: styleguide.to_h,
      screenshot: shot.screenshot,
      backgrounds: backgrounds,
    }
  end

  # 2. Hand the context to Claude and get back a self-contained HTML email.
  def generate_email(domain, brief)
    ctx = gather_brand_context(domain)
    anthropic = Anthropic::Client.new # reads ANTHROPIC_API_KEY

    message = anthropic.messages.create(
      model: MODEL,
      max_tokens: 8000,
      system: SYSTEM_PROMPT,
      messages: [{
        role: "user",
        content: [
          { type: "text", text: "Brand: #{ctx[:name]}. Build: #{brief}." },
          { type: "text", text: "Logo: #{ctx[:logo]}\nColors: #{ctx[:colors].to_json}" },
          { type: "text", text: "Design tokens (styleguide):\n#{ctx[:styleguide].to_json}" },
          { type: "text", text: "Background images: #{ctx[:backgrounds].to_json}" },
          { type: "image", source: { type: "url", url: ctx[:screenshot] } },
        ],
      }]
    )

    message.content.find { |b| b.type.to_s == "text" }&.text.to_s
  end

  # 3. Lint the generated HTML for email-client gotchas.
  def lint_email_html(html)
    problems = []
    kb = html.bytesize / 1024.0

    problems << format("HTML is %dKB; Gmail clips above ~102KB.", kb) if kb > 102
    problems << "Contains a script tag, which every client strips." if html =~ /<script/i
    problems << "Links remote CSS; inline it instead." if html =~ /<link[^>]+stylesheet/i
    problems << "Uses flexbox/grid; unreliable in Outlook." if html =~ /display\s*:\s*(flex|grid)/i
    html.scan(/<img\b[^>]*>/i).each do |img|
      problems << "An img is missing an explicit width." unless img =~ /\bwidth=/i
      problems << "An img is missing alt text." unless img =~ /\balt=/i
    end
    problems
  end

  if __FILE__ == $PROGRAM_NAME
    domain = ARGV[0] || "stripe.com"
    brief = ARGV[1] || "a welcome email"
    html = generate_email(domain, brief)
    File.write("email.html", html)
    issues = lint_email_html(html)
    puts issues.empty? ? "Lint clean." : "Lint issues: #{issues.join(' | ')}"
    puts "Wrote email.html"
  end
  ```

  ```go branded_email.go theme={null}
  package main

  import (
  	"context"
  	"encoding/json"
  	"fmt"
  	"os"
  	"regexp"

  	"github.com/anthropics/anthropic-sdk-go"
  	anthropicopt "github.com/anthropics/anthropic-sdk-go/option"
  	contextdev "github.com/context-dot-dev/context-go-sdk"
  	cdopt "github.com/context-dot-dev/context-go-sdk/option"
  	cdparam "github.com/context-dot-dev/context-go-sdk/packages/param"
  )

  const model = "claude-opus-4-8" // Opus 4.8 for quality; Sonnet 4.6 for cheaper drafts

  var cd = contextdev.NewClient(cdopt.WithAPIKey(os.Getenv("CONTEXT_DEV_API_KEY")))

  type brandContext struct {
  	Name, Logo, Screenshot string
  	Colors, Styleguide     any
  	Backgrounds            []string
  }

  // 1. Gather the homepage's brand context.
  func gatherBrandContext(ctx context.Context, domain string) (*brandContext, error) {
  	home := "https://" + domain
  	brand, err := cd.Brand.Get(ctx, contextdev.BrandGetParams{OfByDomain: &contextdev.BrandGetParamsBodyByDomain{Domain: domain}})
  	if err != nil {
  		return nil, err
  	}
  	sg, err := cd.Web.ExtractStyleguide(ctx, contextdev.WebExtractStyleguideParams{Domain: cdparam.NewOpt(domain)})
  	if err != nil {
  		return nil, err
  	}
  	shot, err := cd.Web.Screenshot(ctx, contextdev.WebScreenshotParams{
  		DirectURL:         cdparam.NewOpt(home),
  		HandleCookiePopup: contextdev.WebScreenshotParamsHandleCookiePopupTrue,
  	})
  	if err != nil {
  		return nil, err
  	}
  	imgs, err := cd.Web.WebScrapeImages(ctx, contextdev.WebWebScrapeImagesParams{URL: home})
  	if err != nil {
  		return nil, err
  	}

  	logo := ""
  	for _, l := range brand.Brand.Logos {
  		if l.Type == "logo" && l.Mode == "light" {
  			logo = l.URL
  			break
  		}
  	}
  	if logo == "" && len(brand.Brand.Logos) > 0 {
  		logo = brand.Brand.Logos[0].URL
  	}
  	var backgrounds []string
  	for _, i := range imgs.Images {
  		if i.Element == "background" || i.Element == "css" {
  			backgrounds = append(backgrounds, i.Src)
  		}
  	}

  	return &brandContext{
  		Name:        brand.Brand.Title,
  		Logo:        logo,
  		Colors:      sg.Styleguide.Colors,
  		Styleguide:  sg.Styleguide,
  		Screenshot:  shot.Screenshot,
  		Backgrounds: backgrounds,
  	}, nil
  }

  // 2. Hand the context to Claude and get back a self-contained HTML email.
  func generateEmail(ctx context.Context, domain, brief string) (string, error) {
  	bc, err := gatherBrandContext(ctx, domain)
  	if err != nil {
  		return "", err
  	}
  	systemPrompt, err := os.ReadFile("system-prompt.txt")
  	if err != nil {
  		return "", err
  	}
  	ai := anthropic.NewClient(anthropicopt.WithAPIKey(os.Getenv("ANTHROPIC_API_KEY")))

  	colors, _ := json.Marshal(bc.Colors)
  	styleguide, _ := json.Marshal(bc.Styleguide)
  	backgrounds, _ := json.Marshal(bc.Backgrounds)

  	msg, err := ai.Messages.New(ctx, anthropic.MessageNewParams{
  		Model:     anthropic.Model(model),
  		MaxTokens: 8000,
  		System:    []anthropic.TextBlockParam{{Text: string(systemPrompt)}},
  		Messages: []anthropic.MessageParam{anthropic.NewUserMessage(
  			anthropic.NewTextBlock(fmt.Sprintf("Brand: %s. Build: %s.", bc.Name, brief)),
  			anthropic.NewTextBlock(fmt.Sprintf("Logo: %s\nColors: %s", bc.Logo, colors)),
  			anthropic.NewTextBlock(fmt.Sprintf("Design tokens (styleguide):\n%s", styleguide)),
  			anthropic.NewTextBlock(fmt.Sprintf("Background images: %s", backgrounds)),
  			anthropic.NewImageBlock(anthropic.URLImageSourceParam{URL: bc.Screenshot}),
  		)},
  	})
  	if err != nil {
  		return "", err
  	}
  	for _, b := range msg.Content {
  		if b.Type == "text" {
  			return b.Text, nil
  		}
  	}
  	return "", nil
  }

  // 3. Lint the generated HTML for email-client gotchas.
  var (
  	reScript = regexp.MustCompile(`(?i)<script`)
  	reLink   = regexp.MustCompile(`(?i)<link[^>]+stylesheet`)
  	reLayout = regexp.MustCompile(`(?i)display\s*:\s*(flex|grid)`)
  	reImg    = regexp.MustCompile(`(?i)<img\b[^>]*>`)
  	reWidth  = regexp.MustCompile(`(?i)\bwidth=`)
  	reAlt    = regexp.MustCompile(`(?i)\balt=`)
  )

  func lintEmailHTML(html string) []string {
  	var problems []string
  	if kb := len(html) / 1024; kb > 102 {
  		problems = append(problems, fmt.Sprintf("HTML is %dKB; Gmail clips above ~102KB.", kb))
  	}
  	if reScript.MatchString(html) {
  		problems = append(problems, "Contains a script tag, which every client strips.")
  	}
  	if reLink.MatchString(html) {
  		problems = append(problems, "Links remote CSS; inline it instead.")
  	}
  	if reLayout.MatchString(html) {
  		problems = append(problems, "Uses flexbox/grid; unreliable in Outlook.")
  	}
  	for _, img := range reImg.FindAllString(html, -1) {
  		if !reWidth.MatchString(img) {
  			problems = append(problems, "An img is missing an explicit width.")
  		}
  		if !reAlt.MatchString(img) {
  			problems = append(problems, "An img is missing alt text.")
  		}
  	}
  	return problems
  }

  func main() {
  	domain, brief := "stripe.com", "a welcome email"
  	if len(os.Args) > 1 {
  		domain = os.Args[1]
  	}
  	if len(os.Args) > 2 {
  		brief = os.Args[2]
  	}
  	html, err := generateEmail(context.TODO(), domain, brief)
  	if err != nil {
  		panic(err)
  	}
  	if err := os.WriteFile("email.html", []byte(html), 0o644); err != nil {
  		panic(err)
  	}
  	if problems := lintEmailHTML(html); len(problems) > 0 {
  		fmt.Println("Lint issues:", problems)
  	} else {
  		fmt.Println("Lint clean.")
  	}
  	fmt.Println("Wrote email.html")
  }
  ```

  ```php branded_email.php theme={null}
  <?php

  use ContextDev\Client;

  $contextdev = new Client(apiKey: getenv('CONTEXT_DEV_API_KEY'));
  const MODEL = 'claude-opus-4-8'; // Opus 4.8 for quality; Sonnet 4.6 for cheaper drafts
  $SYSTEM_PROMPT = file_get_contents(__DIR__ . '/system-prompt.txt');

  // 1. Gather the homepage's brand context.
  function gatherBrandContext(string $domain): array
  {
      global $contextdev;

      $homepage = "https://{$domain}";
      $brandRes = $contextdev->brand->retrieve(type: 'by_domain', domain: $domain);
      $sgRes = $contextdev->web->extractStyleguide(domain: $domain);
      $shot = $contextdev->web->screenshot(directURL: $homepage, handleCookiePopup: 'true');
      $imgs = $contextdev->web->webScrapeImages(url: $homepage);

      $brand = $brandRes->brand;
      $logos = $brand->logos ?? [];
      $lightLogo = null;
      foreach ($logos as $l) {
          if ($l->type === 'logo' && $l->mode === 'light') {
              $lightLogo = $l;
              break;
          }
      }
      $lightLogo ??= $logos[0] ?? null;

      $backgrounds = [];
      foreach ($imgs->images ?? [] as $i) {
          if ($i->element === 'background' || $i->element === 'css') {
              $backgrounds[] = $i->src;
          }
      }

      return [
          'name' => $brand->title ?? $domain,
          'logo' => $lightLogo->url ?? null,
          'colors' => $sgRes->styleguide->colors,
          'styleguide' => $sgRes->styleguide,
          'screenshot' => $shot->screenshot ?? '',
          'backgrounds' => $backgrounds,
      ];
  }

  // 2. Hand the context to Claude and get back a self-contained HTML email.
  function generateEmail(string $domain, string $brief): string
  {
      $ctx = gatherBrandContext($domain);

      $payload = json_encode([
          'model' => MODEL,
          'max_tokens' => 8000,
          'system' => $SYSTEM_PROMPT,
          'messages' => [[
              'role' => 'user',
              'content' => [
                  ['type' => 'text', 'text' => "Brand: {$ctx['name']}. Build: {$brief}."],
                  ['type' => 'text', 'text' => 'Logo: ' . $ctx['logo'] . "\nColors: " . json_encode($ctx['colors'])],
                  ['type' => 'text', 'text' => "Design tokens (styleguide):\n" . json_encode($ctx['styleguide'])],
                  ['type' => 'text', 'text' => 'Background images: ' . json_encode($ctx['backgrounds'])],
                  ['type' => 'image', 'source' => ['type' => 'url', 'url' => $ctx['screenshot']]],
              ],
          ]],
      ]);

      $ch = curl_init('https://api.anthropic.com/v1/messages');
      curl_setopt_array($ch, [
          CURLOPT_POST => true,
          CURLOPT_RETURNTRANSFER => true,
          CURLOPT_HTTPHEADER => [
              'Content-Type: application/json',
              'x-api-key: ' . getenv('ANTHROPIC_API_KEY'),
              'anthropic-version: 2023-06-01',
          ],
          CURLOPT_POSTFIELDS => $payload,
      ]);
      $response = json_decode(curl_exec($ch), true);

      foreach ($response['content'] ?? [] as $block) {
          if (($block['type'] ?? '') === 'text') {
              return $block['text'];
          }
      }

      return '';
  }

  // 3. Lint the generated HTML for email-client gotchas.
  function lintEmailHtml(string $html): array
  {
      $problems = [];
      $kb = strlen($html) / 1024;

      if ($kb > 102) {
          $problems[] = sprintf('HTML is %dKB; Gmail clips above ~102KB.', (int) $kb);
      }
      if (preg_match('/<script/i', $html)) {
          $problems[] = 'Contains a script tag, which every client strips.';
      }
      if (preg_match('/<link[^>]+stylesheet/i', $html)) {
          $problems[] = 'Links remote CSS; inline it instead.';
      }
      if (preg_match('/display\s*:\s*(flex|grid)/i', $html)) {
          $problems[] = 'Uses flexbox/grid; unreliable in Outlook.';
      }

      preg_match_all('/<img\b[^>]*>/i', $html, $matches);
      foreach ($matches[0] ?? [] as $img) {
          if (!preg_match('/\bwidth=/i', $img)) {
              $problems[] = 'An img is missing an explicit width.';
          }
          if (!preg_match('/\balt=/i', $img)) {
              $problems[] = 'An img is missing alt text.';
          }
      }

      return $problems;
  }

  $domain = $argv[1] ?? 'stripe.com';
  $brief = $argv[2] ?? 'a welcome email';
  $html = generateEmail($domain, $brief);
  file_put_contents('email.html', $html);
  $issues = lintEmailHtml($html);
  echo $issues ? 'Lint issues: ' . implode(' | ', $issues) . PHP_EOL : "Lint clean.\n";
  echo "Wrote email.html\n";
  ```
</CodeGroup>

## Related resources

<CardGroup cols={2}>
  <Card title="Brand API" icon="image" href="/guides/get-brand-data">
    Logos, backdrops, slogans, socials, and standard page links from a domain.
  </Card>

  <Card title="Styleguide API" icon="swatchbook" href="/guides/extract-design-system-from-website#extract-the-full-styleguide">
    Typography, colors, spacing, shadows, and component CSS in one call.
  </Card>

  <Card title="Screenshot API" icon="camera" href="/guides/take-webpage-screenshot">
    Capture the homepage and standard pages as hosted PNGs for visual reference.
  </Card>

  <Card title="Best Practices" icon="list-check" href="/optimization/best-practices">
    Caching, error handling, and key hygiene across the API.
  </Card>
</CardGroup>
