Hero CTA review
Does your landing page's main call to action work? A computer-use agent looks at the page the way a visitor does and picks the hero's main button; then that button is measured in the page, its link is checked, and a short critique says what to change.
When to use it: You want to know whether your landing page's main call to action works for a visitor.
| Uses | a session, an agent run in mode computer (screenshots, it only looks), evaluate (measure-cta.js), fetch, extract |
|---|---|
| Needs | a model with a computer-use tool on the API (no model key of your own) |
| Site | your landing page; the default is Firefox's page on mozilla.org; the runner uses a stand-in landing page |
| Output | output/review.md, output/hero.png, output/result.json |
| Languages | Node and Python |
Seeing and measuring are split on purpose: the agent judges like a person (which button stands out, what competes with it), and the page gives exact numbers for that element (text contrast against WCAG AA, size, whether it is above the fold, its accessible name), so the critique rests on facts. The link is opened in a sandboxed browser to check it leads somewhere.
The code
The example as it is in its folder. Both versions do the same job and write the same output/, so one result check covers both.
/** * Hero CTA review: does the landing page's main call to action work? A computer-use agent looks at the page the way a * visitor does and picks the hero's main button; then the button is measured in the page (contrast, size, position, * accessible name), its link is checked, and a short critique says what to change. * * npx tsx node/index.ts (BOXLINE_API_KEY; PAGE_URL; a model with a computer-use tool on the API) * * The agent only looks (it is told not to click or type). Writes output/result.json, output/review.md, output/hero.png. */import { mkdirSync, readFileSync, writeFileSync } from "node:fs";import { dirname, join } from "node:path";import { fileURLToPath } from "node:url";import { Boxline } from "@boxline/sdk";const here = dirname(fileURLToPath(import.meta.url));const out = process.env.OUTPUT_DIR ?? "output";const url = process.env.PAGE_URL ?? "https://www.mozilla.org/en-US/firefox/";const measure = readFileSync(join(here, "../measure-cta.js"), "utf8").replace(/^\s*\/\/.*$/gm, "").trim();const bx = new Boxline();type Seen = { text: string; why: string; otherCandidates: string[] };type Measured = { tag: string; text: string; accessibleName: string; href: string | null; color: string; background: string; contrast: number; needs: number; fontSizePx: number; width: number; height: number; centre: { x: number; y: number }; aboveTheFold: boolean; otherButtons: string[] };const session = await bx.sessions.create({ viewport: { width: 1280, height: 800 }, timeout: 600, idleTimeout: 300, userMetadata: { example: "hero-cta-review" } });console.log(`Session: ${session.id}`);mkdirSync(out, { recursive: true });let seen!: Seen;let cta: Measured | null = null;let runId = "";try { await session.goto(url, { waitUntil: "load" }); writeFileSync(join(out, "hero.png"), Buffer.from((await session.screenshot()).data, "base64")); // 1. What a visitor sees as the main call to action: the agent works from screenshots (computer mode). const started = await bx.agent.run({ sessionId: session.id, mode: "computer", maxSteps: 6, task: `Look at the page that is open (do not click, type or scroll away). In the hero section at the top, which is the main call-to-action button or link: the one the page most wants a visitor to press? Give its exact visible text, why it is the main one, and the texts of other buttons or links that compete with it in the hero.`, output: { type: "object", properties: { text: { type: "string", description: "the button's visible text exactly as written on it, and nothing else (no quotes, no sentence)" }, why: { type: "string", description: "one sentence" }, otherCandidates: { type: "array", items: { type: "string" }, description: "the visible texts of competing buttons or links" }, }, required: ["text", "why", "otherCandidates"], }, }); console.log(`Agent run ${started.id} (${started.model}, computer use) is looking at the hero…`); runId = started.id; const run = await bx.agent.wait<Seen>(started.id); if (run.status !== "completed" || !run.result) throw new Error(`the agent run ${run.status}: ${run.error ?? "no answer"}`); seen = run.result; console.log(`Main CTA: "${seen.text}" (${seen.why})`); // 2. The same element, measured exactly in the page. cta = await session.evaluate<Measured | null>(`(${measure})(${JSON.stringify(seen.text)})`);} finally { await session.stop();}if (!cta) throw new Error(`no button or link with the text "${seen.text}" was found in the page`);// 3. Where it leads (a sandboxed browser opens the link).const link = cta.href && /^https?:/.test(cta.href) ? await bx.fetch(cta.href, { format: "text" }).then((p) => ({ status: p.status, title: p.title, finalUrl: p.finalUrl }), (e: Error) => ({ status: null, title: "", finalUrl: null, error: e.message })) : null;const problems = [ cta.contrast < cta.needs && `text contrast ${cta.contrast}:1 (${cta.color} on ${cta.background}) is below WCAG AA's ${cta.needs}:1`, (cta.width < 44 || cta.height < 44) && `${cta.width} × ${cta.height} px is small to tap (44 × 44 is comfortable)`, !cta.aboveTheFold && "it is not fully visible without scrolling", !cta.accessibleName && "it has no accessible name", link && (link.status === null || link.status >= 400) && `its link answers ${link.status ?? "nothing"}`,].filter(Boolean) as string[];// 4. A short critique from the measurements.const r = await bx.extract<{ verdict: string; suggestions: string[] }>({ url, prompt: `The landing page's main call to action is "${cta.text}". Measured: ${JSON.stringify({ ...cta, otherButtons: cta.otherButtons, link, problems })}. Competing calls to action the visitor sees: ${seen.otherCandidates.join("; ") || "none"}. verdict: two sentences on how well it works. suggestions: up to 4 concrete changes (wording, colour, size, placement), each grounded in the measurements or the page.`, schema: { type: "object", properties: { verdict: { type: "string" }, suggestions: { type: "array", items: { type: "string" } } }, required: ["verdict", "suggestions"] },});console.log(`Measured: ${cta.width}×${cta.height} px at (${cta.centre.x}, ${cta.centre.y}), ${cta.color} on ${cta.background} = ${cta.contrast}:1, ${cta.aboveTheFold ? "above the fold" : "below the fold"}`);console.log(`Link: ${cta.href} → ${link ? `${link.status} "${link.title}"` : "none"}`);for (const p of problems) console.log(` ! ${p}`);console.log(`\n${r.data.verdict}\n${r.data.suggestions.map((s) => ` - ${s}`).join("\n")}`);const md = [`# Hero CTA review: ${url}`, "", `**"${cta.text}"**: ${seen.why}`, "", r.data.verdict, "", "## Measured", "", `- ${cta.width} × ${cta.height} px, ${cta.aboveTheFold ? "above the fold" : "below the fold"}`, `- ${cta.color} on ${cta.background}: ${cta.contrast}:1 (needs ${cta.needs}:1)`, `- Leads to ${cta.href ?? "nothing"}${link ? ` (${link.status})` : ""}`, ...problems.map((p) => `- Problem: ${p}`), "", "## Suggestions", "", ...r.data.suggestions.map((s) => `- ${s}`), "", "", ""].join("\n");writeFileSync(join(out, "review.md"), md);writeFileSync(join(out, "result.json"), JSON.stringify({ url, seen, cta, link, problems, verdict: r.data.verdict, suggestions: r.data.suggestions, agentRuns: [{ id: runId }], usage: { modelUsd: r.usage.costUsd } }, null, 2));"""Hero CTA review: does the landing page's main call to action work? A computer-use agent looks at the page the way avisitor does and picks the hero's main button; then the button is measured in the page (contrast, size, position,accessible name), its link is checked, and a short critique says what to change. python python/main.py (BOXLINE_API_KEY; PAGE_URL; a model with a computer-use tool on the API)The agent only looks (it is told not to click or type). Writes output/result.json, output/review.md, output/hero.png."""import jsonimport osimport refrom pathlib import Pathfrom boxline import Boxline, BoxlineErrorhere = Path(__file__).resolve().parentout = Path(os.environ.get("OUTPUT_DIR", "output"))url = os.environ.get("PAGE_URL", "https://www.mozilla.org/en-US/firefox/")measure = re.sub(r"^\s*//.*$", "", (here.parent / "measure-cta.js").read_text(), flags=re.M).strip()bx = Boxline()out.mkdir(parents=True, exist_ok=True)with bx.sessions.create(viewport={"width": 1280, "height": 800}, timeout=600, idle_timeout=300, user_metadata={"example": "hero-cta-review"}) as session: print(f"Session: {session.id}", flush=True) session.goto(url, wait_until="load") (out / "hero.png").write_bytes(session.screenshot()) # 1. What a visitor sees as the main call to action: the agent works from screenshots (computer mode). started = bx.agent.run( "Look at the page that is open (do not click, type or scroll away). In the hero section at the top, which is the main call-to-action button or link: " "the one the page most wants a visitor to press? Give its exact visible text, why it is the main one, and the texts of other buttons or links that compete with it in the hero.", session_id=session.id, mode="computer", max_steps=6, output={ "type": "object", "properties": { "text": {"type": "string", "description": "the button's visible text exactly as written on it, and nothing else (no quotes, no sentence)"}, "why": {"type": "string", "description": "one sentence"}, "otherCandidates": {"type": "array", "items": {"type": "string"}, "description": "the visible texts of competing buttons or links"}, }, "required": ["text", "why", "otherCandidates"], }, ) print(f"Agent run {started['id']} ({started['model']}, computer use) is looking at the hero…", flush=True) run = bx.agent.wait(started["id"]) if run["status"] != "completed" or not run["result"]: raise SystemExit(f"the agent run {run['status']}: {run.get('error') or 'no answer'}") seen = run["result"] print(f"Main CTA: \"{seen['text']}\" ({seen['why']})") # 2. The same element, measured exactly in the page. cta = session.evaluate(f"({measure})({json.dumps(seen['text'])})")if not cta: raise SystemExit(f"no button or link with the text \"{seen['text']}\" was found in the page")# 3. Where it leads (a sandboxed browser opens the link).link = Noneif cta["href"] and re.match(r"^https?:", cta["href"]): try: p = bx.fetch(cta["href"], format="text") link = {"status": p["status"], "title": p["title"], "finalUrl": p["finalUrl"]} except BoxlineError as e: link = {"status": None, "title": "", "finalUrl": None, "error": str(e)}problems = [ p for p in [ cta["contrast"] < cta["needs"] and f"text contrast {cta['contrast']}:1 ({cta['color']} on {cta['background']}) is below WCAG AA's {cta['needs']}:1", (cta["width"] < 44 or cta["height"] < 44) and f"{cta['width']} × {cta['height']} px is small to tap (44 × 44 is comfortable)", not cta["aboveTheFold"] and "it is not fully visible without scrolling", not cta["accessibleName"] and "it has no accessible name", link and (link["status"] is None or link["status"] >= 400) and f"its link answers {link['status'] or 'nothing'}", ] if p]# 4. A short critique from the measurements.r = bx.extract( url=url, prompt=( f"The landing page's main call to action is \"{cta['text']}\". Measured: {json.dumps({**cta, 'link': link, 'problems': problems})}. Competing calls to action the visitor sees: " f"{'; '.join(seen['otherCandidates']) or 'none'}. verdict: two sentences on how well it works. suggestions: up to 4 concrete changes (wording, colour, size, placement), each grounded in the measurements or the page." ), schema={"type": "object", "properties": {"verdict": {"type": "string"}, "suggestions": {"type": "array", "items": {"type": "string"}}}, "required": ["verdict", "suggestions"]},)critique = r["data"]print(f"Measured: {cta['width']}×{cta['height']} px at ({cta['centre']['x']}, {cta['centre']['y']}), {cta['color']} on {cta['background']} = {cta['contrast']}:1, {'above the fold' if cta['aboveTheFold'] else 'below the fold'}")print(f"Link: {cta['href']} → " + (f"{link['status']} \"{link['title']}\"" if link else "none"))for p in problems: print(f" ! {p}")print(f"\n{critique['verdict']}\n" + "\n".join(f" - {s}" for s in critique["suggestions"]))md = [f"# Hero CTA review: {url}", "", f"**\"{cta['text']}\"**: {seen['why']}", "", critique["verdict"], "", "## Measured", ""]md += [f"- {cta['width']} × {cta['height']} px, {'above the fold' if cta['aboveTheFold'] else 'below the fold'}", f"- {cta['color']} on {cta['background']}: {cta['contrast']}:1 (needs {cta['needs']}:1)", f"- Leads to {cta['href'] or 'nothing'}" + (f" ({link['status']})" if link else "")]md += [f"- Problem: {p}" for p in problems] + ["", "## Suggestions", ""] + [f"- {s}" for s in critique["suggestions"]] + ["", "", ""](out / "review.md").write_text("\n".join(md))result = {"url": url, "seen": seen, "cta": cta, "link": link, "problems": problems, "verdict": critique["verdict"], "suggestions": critique["suggestions"], "agentRuns": [{"id": run["id"]}], "usage": {"modelUsd": r["usage"]["costUsd"]}}(out / "result.json").write_text(json.dumps(result, indent=2, ensure_ascii=False))// A function of the call-to-action's visible text. Runs in the page (session.evaluate): finds that button or link and// measures it: its colours and WCAG contrast, size, position, accessible name and target. Returns plain JSON, or null.(text) => { const norm = (s) => (s || "").replace(/\s+/g, " ").trim().toLowerCase(); const want = norm(text); const candidates = [...document.querySelectorAll("a, button, [role=button], input[type=submit], input[type=button]")].filter((e) => e.getClientRects().length); const label = (e) => norm(e.innerText || e.value || e.getAttribute("aria-label")); const el = candidates.find((e) => label(e) === want) || candidates.find((e) => label(e).includes(want) || (want.includes(label(e)) && label(e).length > 2)); if (!el) return null; const parse = (c) => { const m = /rgba?\(([^)]+)\)/.exec(c); if (!m) return null; const [r, g, b, a = 1] = m[1].split(",").map(Number); return { r, g, b, a }; }; const channel = (v) => ((v /= 255) <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4); const lum = ({ r, g, b }) => 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); const hex = ({ r, g, b }) => "#" + [r, g, b].map((v) => Math.round(v).toString(16).padStart(2, "0")).join(""); let bg = null; for (let e = el; e && !bg; e = e.parentElement) { const c = parse(getComputedStyle(e).backgroundColor); if (c && c.a > 0.5) bg = c; } bg = bg || { r: 255, g: 255, b: 255 }; const s = getComputedStyle(el); const fg = parse(s.color); const [hi, lo] = [lum(fg), lum(bg)].sort((x, y) => y - x); const px = parseFloat(s.fontSize); const large = px >= 24 || (parseInt(s.fontWeight, 10) >= 700 && px >= 18.66); const r = el.getBoundingClientRect(); return { tag: el.tagName.toLowerCase(), text: (el.innerText || el.value || "").trim(), accessibleName: (el.getAttribute("aria-label") || el.innerText || el.value || "").trim(), href: el.href || (el.form ? el.form.action : null), color: hex(fg), background: hex(bg), contrast: Math.round(((hi + 0.05) / (lo + 0.05)) * 100) / 100, needs: large ? 3 : 4.5, fontSizePx: px, width: Math.round(r.width), height: Math.round(r.height), centre: { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) }, aboveTheFold: r.bottom <= innerHeight && r.top >= 0, otherButtons: candidates.filter((e) => e !== el && e.getBoundingClientRect().bottom <= innerHeight).map((e) => (e.innerText || e.value || "").trim()).filter(Boolean).slice(0, 8), };}Inputs
Environment variables the example reads:
PAGE_URL.
Run it
Get the examples with git clone https://github.com/Boxline-dev/examples.git and open this one: cd examples/hero-cta-review. Put your API key in BOXLINE_API_KEY (and BOXLINE_API_URL for another API), then run either version from the example’s folder. Both write to output/ and stop their sessions when they finish.
# Node 18 or newer, in the example's folderexport BOXLINE_API_KEY=… # your API keynpm install @boxline/sdk tsxnpx tsx node/index.ts# Python 3.9 or newer, in the example's folderexport BOXLINE_API_KEY=… # your API keypip install boxline-sdkpython python/main.pyThen check the result, from the same folder: npx tsx check.ts output. It exits with 0 only when the result is right.
What its check proves
check.ts reads what the example wrote and passes only when the result is right, not merely when the example ran.
A CTA was identified, measured and criticised, with a screenshot. On the runner's stand-in landing page: the agent picks "Start free trial", its white-on-light-blue text is measured at about 1.7:1 (a failure), it sits above the fold and leads to the sign-up page, and a suggestion fixes the colour.
Source
github.com/Boxline-dev/examples/hero-cta-review on GitHub, with its README.md, check.ts and .env.example. The examples are open source. See all examples.
These docs describe a beta API. Endpoints, fields and SDK methods may change before general availability; breaking changes will be listed in the changelog.