Architecture
The pipeline runs in three phases: gather, generate, and test.- 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.
- The Generate step calls an agent to generate the actual email template in HTML and inline CSS.
- 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, oranthropic-sdk-go). - The Context.dev SDK for your backend:
npm install context.dev
pip install context.dev
gem install context.dev
go get github.com/context-dot-dev/context-go-sdk
composer require context-dev/context-dev-php
Step 1. Gather the brand context
These four APIs have all the context our agent needs:- Brand API: the brand profile.
logos[]: logo and icon variants, each withurl,type, andmodebackdrops[]: hero and background imagerylinks: standard page URLs (pricing,blog,login,signup,careers,contact,privacy,terms)title,description,slogan,socials[],address, andindustries
- Styleguide API: the homepage’s design tokens.
mode:lightordarkcolors:accent,background, andtexttypography:headings.h1toh4andp, each withfontFamily,fontFallbacks,fontSize,fontWeight, andlineHeightelementSpacing: anxstoxlspacing scaleshadows:smtoxlplusinnerbox-shadow valuescomponents:buttonandcard, each with ready-to-pastecssfontLinks: downloadable font files keyed by family
- Screenshot API: a hosted render of each page.
screenshot: a CDN URL for the captured PNGscreenshotType:viewportorfullPagewidthandheight: the captured dimensions
- Image Scraping API: each page’s image manifest.
images[]: every image on the page, each withsrc,element(img,svg,css,background, and more),type, andaltimages[].enrichment(optional):width,height,hostedUrl, andclassification
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({ 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,
};
}
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,
}
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
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
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,
];
}
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 namedsystem-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 ?? "";
}
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"), "")
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
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
// 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 '';
}
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:- Lints to find email-client compatibility issues (oversized HTML, script tags, flexbox/grid, missing image attributes)
- 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;
}
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
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
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
// 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;
}
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 anemail_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;
}
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"]
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
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
// 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'] ?? '';
}
Full implementation
Here is the whole pipeline (gather, generate, lint) as one runnable script per language. Each one readsCONTEXT_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({ 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");
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")
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
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
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";
Related resources
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.