Skip to main content
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, Styleguide API, Screenshot API and Image Scraping API 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:

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 and export it as CONTEXT_DEV_API_KEY.
  • An Anthropic API key for the generation step. Grab one from the Anthropic Console, 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:
npm install context.dev

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.
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({ 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,
  };
}
Brand · 10 credits Styleguide · 10 credits Screenshot · 5 credits Image scrape · 1 credit 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.

System prompt for the email generator

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 ?? "";
}

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:
// 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;
}
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 or Email on Acid. 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:
// 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;
}
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 from Step 2 alongside it, takes a domain and a brief as arguments, and writes email.html.
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".
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({ 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");

Brand API

Logos, backdrops, slogans, socials, and standard page links from a domain.

Styleguide API

Typography, colors, spacing, shadows, and component CSS in one call.

Screenshot API

Capture the homepage and standard pages as hosted PNGs for visual reference.

Best Practices

Caching, error handling, and key hygiene across the API.