# Brand Visual Asset Generator

Generate brand-aligned SVG asset packs for SaaS products using Context.dev brand extraction and a Quiver Arrow SVG tool.

- Install: `npx shadcn@latest add @evex/brand-visual-asset-generator`
- Category: marketing
- Author: [TommyBez](https://www.evex.sh/authors/TommyBez)
- Updated: 2026-07-04
- Dependencies: ai@^7.0.11, eve@^0.18.2, zod@4.4.3
- Web page: https://www.evex.sh/agents/brand-visual-asset-generator
- This document: https://www.evex.sh/agents/brand-visual-asset-generator.md

## Overview

Brand Visual Asset Generator is an on-demand eve agent that turns a company domain, product description, or explicit brand profile into a coherent pack of editable SVG assets: icons, empty states, hero illustrations, badges, feature graphics, onboarding visuals, changelog art, and dashboard or modal graphics. You talk to it in your eve chat client, name the assets you want, and it returns real SVG markup ready to drop into a product or landing page.

Under the hood it combines two services. A Context.dev MCP connection resolves the brand facts first: name, description, colors, logos, homepage copy, and styleguide cues for a domain. Then a generate_svg_with_arrow tool calls the quiverai/arrow-1.1 image model through Vercel AI Gateway once per asset brief, validates that the response is safe SVG with a viewBox, rescales it to the requested dimensions, and normalizes icons to currentColor strokes so they theme automatically.

What makes it useful is pack coherence and honesty. A bundled brand-visual-assets skill enforces a six-step workflow with a scorecard: every asset must share palette, stroke language, and illustration metaphors, and anything scoring 8 or below gets regenerated with a tighter brief. If Context.dev or AI Gateway credentials fail, the agent stops and reports the error instead of fabricating brand facts or hand-writing replacement SVGs.

## How it works

1. You send a request in chat, such as a feature launch pack for linear.app; if no website, product description, or brand profile is given, the agent asks for one before doing any generation work.
2. The agent calls the context-dev MCP connection (search_docs and execute against the hosted Context.dev server) to retrieve brand data, homepage markdown, and styleguide details, authenticating with your CONTEXT_DEV_API_KEY via the x-context-dev-api-key header.
3. The brand-visual-assets skill scopes the pack, falling back to a default asset list if you did not name specific types, then locks a brand profile with primary and secondary hex colors, tone adjectives, and up to four reference images such as Context.dev logo or screenshot URLs.
4. For each asset it writes a self-contained brief and calls generate_svg_with_arrow once, which prompts quiverai/arrow-1.1 through Vercel AI Gateway, rejects output lacking a viewBox or containing scripts and raster content, fits the artwork to your requested dimensions, and adds title and desc elements for accessibility.
5. The agent scores every generated asset against the skill's quality-bar, consistency, and visual-taste references, then regenerates any asset scoring 8 or below on the scorecard with a tighter brief.
6. The final reply contains a brand brief with source URLs, an asset pack manifest with filenames and purposes, one fenced svg block per asset, usage notes for theming and placement, and an assumptions section when choices were inferred. Two bundled evals verify the ask-first and no-fabrication behaviors.

## Use cases

### Feature launch pack from a domain

Point the agent at your marketing site and request a hero illustration, feature icons, a new-feature badge, and an onboarding visual. Context.dev extracts your palette and logos so every asset lands on brand without manual specs.

### Empty states for a pre-launch product

No public website yet? Provide a product description, a primary hex color, and tone adjectives, and the agent generates empty-state and modal illustrations directly from that explicit brand profile, listing its assumptions alongside the SVGs.

### Themeable icon sets for a design system

Request feature, navigation, or status icons and get SVGs normalized to currentColor strokes with fill none, a 1.8 stroke width, and rounded caps, so the icons inherit text color and work in light and dark themes.

### Changelog and release visuals on demand

Generate consistent changelog art or dashboard graphics for each release. Because the skill enforces shared palette and stroke language across the pack, week-over-week visuals stay coherent instead of drifting in style.

## Requirements

- `CONTEXT_DEV_API_KEY`: Context.dev API key used by the MCP connection for brand, webpage, and styleguide extraction; sent only from the eve runtime as the x-context-dev-api-key header. Create one in your Context.dev account. CONTEXT_API_KEY is accepted as a fallback name.
- `AI_GATEWAY_API_KEY`: Vercel AI Gateway key (vck_ prefix) that lets generate_svg_with_arrow call the quiverai/arrow-1.1 image model. Create it in your Vercel AI Gateway project and make sure that model is enabled there.
- `VERCEL_OIDC_TOKEN`: Alternative AI Gateway credential. On Vercel deployments it is provided automatically; locally you can pull one with the Vercel CLI. Set either this or AI_GATEWAY_API_KEY, not necessarily both.
- `eve, ai, zod`: Runtime dependencies installed with the registry item: the eve framework (^0.18.2), the ai SDK (^7.0.11) for generateImage, and zod 4.4.3 for tool input validation. Node.js 24 or newer is required.

## FAQ

### How do I install and run this agent?

Run npx shadcn@latest add @evex/brand-visual-asset-generator in your eve app, copy .env.example into your environment with the Context.dev and AI Gateway credentials filled in, then start the app with pnpm dev and ask for a visual pack in chat.

### Which models does it use?

The root orchestrator runs on deepseek/deepseek-v4-pro, and SVG generation uses quiverai/arrow-1.1 called through Vercel AI Gateway's image-generation endpoint. Your deployment needs access to both providers; the agent never asks a language model to draw the SVGs itself.

### Can I customize the assets it produces?

Yes. Name the exact asset types, dimensions, colors, and tone in your request, or let the skill fall back to its default pack. You can also pass up to four reference images per asset for style, palette, composition, or typography direction, stating what to preserve and what to change.

### Does it work without a company website?

Yes. Instead of a domain, give a product description or an explicit brand profile such as a primary hex color, audience, and tone adjectives. The agent skips Context.dev extraction only when you supply the brand facts, and it records inferred choices in an assumptions section.

### What are the known limits and failure modes?

Arrow 1.1 accepts at most four reference images and about 6000 output tokens per asset. Context.dev can return 408 or 429 on cold starts and rate limits, so retry or reduce concurrency. On missing credentials or model errors the agent stops and reports the failure rather than fabricating output.

## Files installed

- `agent/agent.ts`
- `agent/connections/context-dev.ts`
- `agent/instructions.md`
- `agent/skills/brand-visual-assets/references/brief-template.md`
- `agent/skills/brand-visual-assets/references/consistency.md`
- `agent/skills/brand-visual-assets/references/default-pack.md`
- `agent/skills/brand-visual-assets/references/quality-bar.md`
- `agent/skills/brand-visual-assets/references/scorecard.md`
- `agent/skills/brand-visual-assets/references/visual-taste.md`
- `agent/skills/brand-visual-assets/SKILL.md`
- `agent/tools/generate_svg_with_arrow.ts`
- `evals/evals.config.ts`
- `evals/generation-failure-no-fabrication.eval.ts`
- `evals/missing-context-asks.eval.ts`
- `agent/README.md`
- `.env.example`

## File contents

### `agent/agent.ts`

```ts
import { defineAgent } from "eve";

export default defineAgent({
  model: "deepseek/deepseek-v4-pro",
});

```

### `agent/connections/context-dev.ts`

```ts
import { defineMcpClientConnection } from "eve/connections";

export default defineMcpClientConnection({
  url: "https://context-dev.stlmcp.com",
  description:
    "Context.dev hosted MCP for resolving brand data, scraping webpages, crawling sites, and extracting styleguides from domains. Use it to gather brand colors, typography, logos, and product context before generating SVG assets.",
  headers: {
    "x-context-dev-api-key": readContextDevApiKey,
  },
  tools: {
    allow: ["search_docs", "execute"],
  },
});

function readContextDevApiKey(): string {
  const apiKey =
    process.env.CONTEXT_DEV_API_KEY?.trim() || process.env.CONTEXT_API_KEY?.trim();

  if (!apiKey) {
    throw new Error(
      "Missing CONTEXT_DEV_API_KEY or CONTEXT_API_KEY for Context.dev MCP access.",
    );
  }

  return apiKey;
}

```

### `agent/instructions.md`

```md
# Mission
Generate a coherent **pack** of brand-aligned SVG visual assets for SaaS and
digital products. Output structured, editable SVGs that teams can ship directly in
products, websites, landing pages, design systems, and marketing workflows.

# Supported asset types
- Icons (feature, navigation, status)
- Empty states
- Hero illustrations
- Badges and labels (for example "new feature", "beta", "pro")
- Feature graphics
- Onboarding visuals
- Changelog illustrations
- Dashboard or modal visuals

# Workflow
1. If the user has not provided enough context, ask for at least one of:
   - company website or domain;
   - product or feature description;
   - explicit brand profile (colors, tone, audience, product category).
2. Load the `brand-visual-assets` skill and run its pack workflow end to end.
3. Use the `context-dev` MCP connection through `connection_search` to discover
   Context.dev tools. Use `search_docs` when you need exact SDK method or
   parameter names, then use `execute` to gather source data.
4. Through Context.dev MCP, retrieve at minimum:
   - brand data for the domain, including name, description, colors, logos,
     industry labels, and social/profile fields when available;
   - homepage or provided page markdown when a URL is available;
   - styleguide/design-system data for colors, typography, spacing, shadows, and
     component cues when available.
5. Treat Context.dev brand, content, and styleguide outputs as the source of truth
   for brand name, positioning, palette, typography cues, logos, and factual
   product claims.
6. Use up to four reference images when they will improve visual fidelity. Prefer
   Context.dev logo, backdrop, or product screenshot URLs; state what to preserve
   from each reference and what should change for the new asset.
7. Generate each finalized asset by calling `generate_svg_with_arrow`, which uses
   `quiverai/arrow-1.1` through Vercel AI Gateway's image-generation endpoint.
   Use the returned SVG markup as the draft asset, then score it against the
   skill references before including it in the final pack.
8. If `generate_svg_with_arrow` fails because AI Gateway credentials are missing
   or the upstream image model errors, stop and report the configuration or model
   failure. Do not fabricate replacement SVGs.
9. If the Context.dev MCP connection fails because the API key is missing,
   invalid, rate-limited, or unavailable, stop and report the configuration or API
   failure. Do not fabricate brand facts.

# Output contract
Return:
1. A short "Brand brief" section summarizing palette, typography cues, tone, and
   Context.dev source URLs.
2. An "Asset pack" section listing each asset with filename suggestion, purpose,
   and dimensions.
3. Each asset under a Markdown heading that includes the suggested filename,
   followed by exactly one fenced `svg` block containing SVG markup only. Do not
   put filenames inside `svg` fences.
4. A short "Usage notes" section covering light/dark theming, recommended sizes,
   and where each asset fits (marketing page, in-app empty state, onboarding, and
   so on).
5. An "Assumptions" section only when visual choices rely on inference rather
   than explicit source data.

# Guardrails
- Do not invent customer logos, testimonials, statistics, or product claims.
- Do not expose the Context.dev API key or any environment variables.
- Do not output raster-only images or generic image-generation prompts when SVG
  is expected.
- Do not skip brand extraction and guess a palette when a domain or brand profile
  was provided.
- Do not use a language-model subagent for `quiverai/arrow-1.1`; it is an image
  model and must be called through `generate_svg_with_arrow`.

```

### `agent/skills/brand-visual-assets/references/brief-template.md`

```md
# Per-asset brief template

Include every field in each `generate_svg_with_arrow` brief:

| Field | Value |
| --- | --- |
| `assetType` | `icon` \| `empty-state` \| `hero` \| `badge` \| `feature-graphic` \| `onboarding` \| `changelog` \| `dashboard-modal` |
| `filename` | kebab-case ending in `.svg` |
| `purpose` | one sentence on where it ships |
| `dimensions` | viewBox or aspect ratio (for example `1200x630` hero, `24x24` icon) |
| `palette` | named colors with hex from the locked brand profile |
| `subject` | what to depict |
| `text` | exact copy if the SVG includes text |
| `styleNotes` | stroke weight, corner radius, illustration density, metaphors to use or avoid |
| `tasteNotes` | what should make the asset feel sharp, premium, and brand-specific |
| `referenceImages` | optional public image URLs or base64 references from Context.dev logos/backdrops/screenshots when they materially improve style, palette, typography, or composition |
| `preserveFromReference` | when references are used, the exact style, color relationships, layout, typography direction, or structure to preserve |
| `changeFromReference` | when references are used, the subject, dimensions, or asset-specific details that should change |
| `constraints` | for example `currentColor`, no gradients, dark-mode safe |

Every brief must include at least one negative constraint, such as "avoid generic
globe/network art", "avoid repeated decorative dot grids", or "avoid stock SaaS
dashboard cards". Negative constraints prevent the SVG tool from falling back
to obvious visual clichés.

Keep the brief compact and structured. Prefer one concrete vector concept over a
long prose dump of brand facts. Use reference images for brand style or
composition when Context.dev returns suitable logos, backdrops, or source images.

```

### `agent/skills/brand-visual-assets/references/consistency.md`

```md
# Pack consistency

Apply across every asset in the run:

- Reuse the same stroke-width scale and corner-radius family across icons.
- Keep the hero and icons close in line weight: icons may be bolder for legibility,
  but they must not feel chunky next to hairline hero art.
- Limit palette to brand primaries plus one accent and neutrals.
- Keep character or device metaphors consistent between hero, onboarding, and empty
  states.
- Badge and icon geometry should feel like the same design system.
- Use `currentColor` for icons unless the user explicitly asks for fixed brand
  colors.
- Keep hero, empty-state, and onboarding visuals on the same geometry system:
  shared radii, shared depth rules, and a limited set of reusable motifs.
- Prefer one clear focal object over many evenly scattered decorative objects.

```

### `agent/skills/brand-visual-assets/references/default-pack.md`

```md
# Default feature-launch pack

Use when the user asks for a "visual pack" or "feature launch assets" without
listing items:

1. One hero illustration for the feature page
2. Three matching feature icons
3. One dashboard empty state
4. One "new feature" badge
5. One onboarding visual for the in-app flow

Adjust when the user names specific asset types or channels.

```

### `agent/skills/brand-visual-assets/references/quality-bar.md`

```md
# Quality bar

Reject tool output and regenerate the individual asset when any of these fail:

- Not valid SVG markup
- Raster embed without an approved logo URL from brand data
- Color outside the locked brand profile palette
- Invented logo, trademark, or product claim
- Missing `viewBox` on a sized graphic
- Missing `<title>` or `<desc>` when the graphic conveys meaning
- Missing semantic `<g id="...">` groups on hero, empty-state, onboarding, or
  feature-graphic assets
- Hardcoded brand colors in icons that should use `currentColor`
- Repeated primitive filler that could be a pattern, symbol, or omitted entirely
- A generic SaaS cliché when the brief asked for brand-specific visual direction
- Sloppy overlaps, clipped shapes, abrupt path endpoints, or messy crossings
- Badge text that is not optically centered
- Icons that are muddy at 24px or fail when mentally scaled to 16px

Also check `references/consistency.md` before accepting the full pack.

```

### `agent/skills/brand-visual-assets/references/scorecard.md`

```md
# Visual scorecard

Score the full pack from 1 to 10 before accepting it. Accept only scores above 8.

| Dimension | Pass condition |
| --- | --- |
| Composition | One clear focal idea; no chaotic crossings, accidental overlaps, or unbalanced empty regions |
| Brand specificity | Visual metaphor comes from the brand/product context, not generic SaaS decoration |
| Coherence | Hero, icons, and badge share stroke weight, corner radius, palette, and geometry language |
| Craft | Pixel-aligned icons, centered badge text, clean curves, purposeful grouping |
| Implementation | Valid SVG, semantic groups, themeable icons, no raster embeds, no bloated filler |

## Automatic score caps

- Cap at 6 if the hero relies on random node networks, globes, floating dashboard
  cards, or disconnected UI wireframes.
- Cap at 6 if icons use a visibly different stroke weight or visual style from
  the hero.
- Cap at 7 if any icon is muddy at 24px or unclear at 16px.
- Cap at 7 if the badge text is not optically centered.
- Cap at 7 if the SVG uses hardcoded backgrounds where transparency or theming is
  expected.
- Cap at 8 if the pack is clean but generic.

## Regeneration note

When a score is capped, name the cap reason in the next
`generate_svg_with_arrow` brief and ask for a specific replacement metaphor, not
vague polish.

```

### `agent/skills/brand-visual-assets/references/visual-taste.md`

```md
# Visual taste bar

Reject and regenerate any asset that feels generic, cluttered, or decorative
without purpose.

## Prefer

- One memorable focal idea per asset
- Asymmetric composition with deliberate negative space
- Brand-specific geometry, product metaphors, or motion cues from the source
  website
- A restrained palette: primary, one accent, neutral, and background
- Simple reusable structures: `<symbol>`, `<pattern>`, or grouped motifs instead
  of dozens of copied primitives
- Icons that read at 16px and feel crisp at 24px
- Integrated compositions where interface, data, and product metaphors interact
  as one scene

## Avoid

- Generic fintech globes, random network nodes, stock dashboard cards, and
  floating rectangles unless the brief explicitly asks for them
- Split scenes where a literal UI wireframe sits beside unrelated abstract lines
  or floating nodes
- Decorative dot grids that do not support the composition
- Centered "everything connected to everything" layouts
- Gradients, shadows, or glows used to compensate for weak composition
- Text inside tiny icons
- Overly literal currency symbols unless the asset specifically needs payment or
  currency meaning

## Regeneration prompt pattern

When an asset fails this bar, regenerate only that asset with a tighter note:

> The previous asset is too generic. Keep the same palette and dimensions, but
> replace the visual metaphor with [specific direction]. Use fewer shapes, stronger
> negative space, semantic groups, and no decorative filler.

```

### `agent/skills/brand-visual-assets/SKILL.md`

```md
---
name: brand-visual-assets
description: Scope and ship a brand-aligned pack of SVG assets for SaaS products. Use when the user wants a visual pack, feature launch assets, icons, empty states, hero illustrations, badges, feature graphics, onboarding visuals, changelog art, or dashboard/modal graphics.
---

# Pack workflow

Run these steps in order. A **pack** is one coherent set of SVG assets that share
palette, stroke language, and illustration metaphors.

## 1. Scope the pack

List every asset the run will produce: type, filename, purpose, and channel
(marketing page, in-app empty state, onboarding, and so on).

**Done when:** every requested asset has a named slot; no orphan types remain.

If the user gave no item list, load `references/default-pack.md` and adopt that
pack unless they named specific types or channels.

## 2. Lock the brand profile

Capture the palette and tone the pack will obey from Context.dev output or the
user's explicit brand profile.

**Done when:** primary and secondary hex colors, neutral/background tones,
typography personality, logo constraints, product category, audience, and tone
adjectives are all recorded before any brief is written.

Also collect up to four useful reference images when available, such as
Context.dev logo URLs, brand backdrops, or product screenshots. Use them only
when they improve style, color, layout, or typography fidelity.

## 3. Write a brief per asset

For each pack slot, compose one self-contained **brief** for
`generate_svg_with_arrow`.

**Done when:** every slot has a brief containing every field in
`references/brief-template.md`.

## 4. Generate

Call `generate_svg_with_arrow` once per brief. Pass the full brief, asset type,
filename, dimensions, and any reference images selected for that asset.

**Done when:** every brief has a matching tool result with `ok: true` and SVG
markup. If the tool reports missing AI Gateway credentials or an upstream model
error, stop and report the configuration failure instead of inventing SVGs.

## 5. Score the pack

Run the pack through the scorecard before accepting it.

**Done when:** every asset passes `references/quality-bar.md`,
`references/consistency.md`, `references/visual-taste.md`, and scores above 8 on
`references/scorecard.md`; any asset that is ugly, generic, off-palette,
visually incoherent, or scores 8 or below is regenerated individually with a
tighter brief, not the whole pack unless the brand profile changed.

## 6. Deliver the SVGs

Return the actual final SVG markup for every accepted asset. Put each asset under
a Markdown heading that includes the filename, followed by exactly one fenced
`svg` block containing only that asset's SVG markup.

**Done when:** the final answer contains one fenced `svg` block per accepted
asset. A summary that says the pack is complete but omits the SVG blocks is a
failed delivery and must be corrected before finishing.

```

### `agent/tools/generate_svg_with_arrow.ts`

```ts
import { generateImage, NoImageGeneratedError } from "ai";
import { defineTool } from "eve/tools";
import { z } from "zod";

const ARROW_MODEL = "quiverai/arrow-1.1";
const RESPONSE_PREVIEW_LENGTH = 1200;
const MAX_ARROW_REFERENCES = 4;
const BASE64_PATTERN =
  /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u;
const MIN_ACCENT_LIGHTNESS = 0.15;
const MAX_ACCENT_LIGHTNESS = 0.9;
const MIN_ACCENT_SATURATION = 0.25;

const referenceImageSchema = z.union([
  z.object({
    url: z
      .string()
      .url()
      .refine(isHttpUrl, {
        message: "Reference image URL must use http or https.",
      })
      .describe("Public HTTP(S) image URL to use as a style or composition reference."),
  }),
  z.object({
    base64: z
      .string()
      .min(1)
      .refine(isBase64Payload, {
        message: "Reference image must be a standard base64 payload.",
      })
      .describe("Base64-encoded PNG, JPEG, WebP, GIF, or SVG reference image payload."),
  }),
]);

const inputSchema = z.object({
  filename: z
    .string()
    .min(1)
    .describe("Suggested SVG filename, for example feature-hero.svg."),
  assetType: z
    .string()
    .min(1)
    .describe("Asset type, for example hero illustration, icon, badge, or empty state."),
  brief: z
    .string()
    .min(20)
    .describe("Self-contained creative brief with brand profile, subject, style notes, and constraints."),
  dimensions: z
    .string()
    .min(1)
    .describe("Intended dimensions or viewBox, for example 1200x720 or 0 0 64 64."),
  referenceImages: z
    .array(referenceImageSchema)
    .max(MAX_ARROW_REFERENCES)
    .optional()
    .describe(
      "Optional Quiver reference images for style, palette, composition, or typography. Arrow 1.1 supports up to 4.",
    ),
});

type GenerateSvgInput = z.infer<typeof inputSchema>;
type ReferenceImageInput = z.infer<typeof referenceImageSchema>;
type QuiverReference = { base64: string } | { url: string };

type GenerateSvgOutput =
  | {
      byteLength: number;
      filename: string;
      mediaType: "image/svg+xml";
      model: typeof ARROW_MODEL;
      ok: true;
      revisedPrompt?: string;
      svg: string;
    }
  | {
      error: string;
      filename: string;
      missingEnv?: "AI_GATEWAY_API_KEY or VERCEL_OIDC_TOKEN";
      ok: false;
      responsePreview?: string;
      status?: number;
    };

export default defineTool({
  description:
    "Generate one editable SVG asset by calling the Quiver Arrow image model through Vercel AI Gateway. Use once per finalized asset brief.",
  inputSchema,
  async execute(input): Promise<GenerateSvgOutput> {
    const credential = readGatewayCredential();
    if (!credential) {
      return {
        ok: false,
        filename: input.filename,
        error:
          "AI Gateway credentials are required to call quiverai/arrow-1.1. Set AI_GATEWAY_API_KEY or VERCEL_OIDC_TOKEN.",
        missingEnv: "AI_GATEWAY_API_KEY or VERCEL_OIDC_TOKEN",
      };
    }

    const prompt = buildPrompt(input);
    const generatedSvg = await generateSvgWithGateway({
      prompt,
      referenceImages: input.referenceImages,
    });
    if (!generatedSvg.ok) {
      return {
        ok: false,
        filename: input.filename,
        error: generatedSvg.error,
        responsePreview: generatedSvg.responsePreview,
      };
    }

    const rawSvg = generatedSvg.svg;
    if (!looksLikeSvg(rawSvg)) {
      return {
        ok: false,
        filename: input.filename,
        error: "Quiver Arrow returned data that does not look like SVG markup.",
        responsePreview: preview(rawSvg),
      };
    }

    if (!hasViewBox(rawSvg)) {
      return {
        ok: false,
        filename: input.filename,
        error: "Quiver Arrow returned SVG markup without a viewBox.",
        responsePreview: preview(rawSvg),
      };
    }

    if (hasUnsafeSvgContent(rawSvg)) {
      return {
        ok: false,
        filename: input.filename,
        error: "Quiver Arrow returned SVG markup with raster or active content.",
        responsePreview: preview(rawSvg),
      };
    }

    const normalizedSvg = normalizeViewBox(rawSvg, input);
    const themeableSvg = enforceThemeableIconSvg(normalizedSvg, input);
    const svg = ensureAccessibleSvg(themeableSvg, input);
    return {
      ok: true,
      filename: input.filename,
      model: ARROW_MODEL,
      mediaType: "image/svg+xml",
      byteLength: Buffer.byteLength(svg, "utf8"),
      svg,
    };
  },
  toModelOutput(output) {
    return {
      type: "json",
      value: output,
    };
  },
});

function readGatewayCredential(): string | undefined {
  return (
    process.env.AI_GATEWAY_API_KEY?.trim() ||
    process.env.VERCEL_OIDC_TOKEN?.trim()
  );
}

function buildPrompt(input: GenerateSvgInput): string {
  return [
    JSON.stringify(
      {
        subject: input.brief,
        intended_use: `${input.assetType} saved as ${input.filename}`,
        style:
          "Production-ready SVG/vector output. Use one clear vector concept, clean geometry, and deliberate negative space.",
        composition: `Fit ${input.dimensions}. Prefer a simple, balanced composition over dense UI mockups or many small details.`,
        color_palette:
          "Use only colors named in the brief. For icons, use currentColor for strokes and no fixed fills except one purposeful accent if requested.",
        typography:
          "Avoid text unless the brief explicitly requires exact copy. If text is required, keep it short and optically centered.",
        preserve_from_reference:
          "When referenceImages are provided, preserve the reference style, color relationships, composition, and typography direction.",
        change_from_reference:
          "Change only the subject, dimensions, and asset-specific details requested in the brief.",
        constraints:
          "Return complete editable SVG markup only. Include viewBox, title, desc, and semantic group ids. Do not embed raster images. Avoid generic SaaS filler such as globes, random node networks, decorative dot grids, and disconnected floating dashboards.",
      },
      null,
      2,
    ),
  ].join("\n");
}

async function generateSvgWithGateway({
  prompt,
  referenceImages,
}: {
  prompt: string;
  referenceImages?: ReferenceImageInput[];
}): Promise<{ ok: true; svg: string } | { error: string; ok: false; responsePreview?: string }> {
  try {
    const { image } = await generateImage({
      model: ARROW_MODEL,
      prompt: buildImagePrompt({ prompt, referenceImages }),
      n: 1,
      providerOptions: {
        quiverai: {
          operation: "generate",
          instructions:
            "Return a complete SVG document only. The SVG must be editable markup, not raster data.",
          maxOutputTokens: 6000,
          references: normalizeReferenceImages(referenceImages),
          temperature: 0.45,
          topP: 0.9,
        },
      },
    });

    return {
      ok: true,
      svg: new TextDecoder().decode(image.uint8Array).trim(),
    };
  } catch (error) {
    if (NoImageGeneratedError.isInstance(error)) {
      return {
        ok: false,
        error: "AI Gateway did not return SVG image data.",
        responsePreview: preview(String(error.cause ?? error.message)),
      };
    }

    return {
      ok: false,
      error: "Quiver Arrow SVG generation failed through AI Gateway.",
      responsePreview: preview(errorMessage(error)),
    };
  }
}

function buildImagePrompt({
  prompt,
  referenceImages,
}: {
  prompt: string;
  referenceImages?: ReferenceImageInput[];
}): string | { images: string[]; text: string } {
  const base64Images =
    referenceImages?.flatMap((referenceImage) =>
      "base64" in referenceImage ? [referenceImage.base64] : [],
    ) ?? [];

  if (base64Images.length === 0) {
    return prompt;
  }

  return {
    text: prompt,
    images: base64Images,
  };
}

function normalizeReferenceImages(
  referenceImages: ReferenceImageInput[] | undefined,
): QuiverReference[] | undefined {
  if (!referenceImages || referenceImages.length === 0) {
    return undefined;
  }

  return referenceImages.map((referenceImage) => {
    if ("url" in referenceImage) {
      return { url: referenceImage.url };
    }

    return { base64: referenceImage.base64 };
  });
}

function errorMessage(error: unknown): string {
  if (error instanceof Error) {
    return error.message;
  }

  return String(error);
}

function looksLikeSvg(value: string): boolean {
  return value.includes("<svg") && value.includes("</svg>");
}

function hasViewBox(value: string): boolean {
  return /\sviewBox\s*=/iu.test(value);
}

function hasUnsafeSvgContent(svg: string): boolean {
  return (
    /<\s*(script|foreignObject|iframe|object|embed|image)\b/iu.test(svg) ||
    /\son[a-z]+\s*=/iu.test(svg) ||
    /\b(?:href|xlink:href)\s*=\s*["']\s*(?:javascript:|data:(?!image\/svg\+xml)|https?:\/\/|\/\/)/iu.test(svg)
  );
}

type ViewBox = {
  height: number;
  minX: number;
  minY: number;
  width: number;
};

function normalizeViewBox(
  svg: string,
  input: z.infer<typeof inputSchema>,
): string {
  const targetViewBox = parseTargetViewBox(input.dimensions);
  const sourceViewBox = parseSvgViewBox(svg);

  if (!targetViewBox || !sourceViewBox || sameViewBox(sourceViewBox, targetViewBox)) {
    return svg;
  }

  const innerSvg = extractInnerSvg(svg);
  if (!innerSvg) {
    return svg;
  }

  const scale = Math.min(
    targetViewBox.width / sourceViewBox.width,
    targetViewBox.height / sourceViewBox.height,
  );
  const scaledWidth = sourceViewBox.width * scale;
  const scaledHeight = sourceViewBox.height * scale;
  const translateX =
    targetViewBox.minX +
    (targetViewBox.width - scaledWidth) / 2 -
    sourceViewBox.minX * scale;
  const translateY =
    targetViewBox.minY +
    (targetViewBox.height - scaledHeight) / 2 -
    sourceViewBox.minY * scale;
  const transform = `translate(${formatNumber(translateX)} ${formatNumber(translateY)}) scale(${formatNumber(scale)})`;
  const outerTag = buildSvgOpeningTag(svg, targetViewBox);

  return `${outerTag}\n<g id="arrow-generated-artwork" transform="${transform}">\n${innerSvg.trim()}\n</g>\n</svg>`;
}

function parseTargetViewBox(value: string): ViewBox | undefined {
  const viewBoxParts = value
    .trim()
    .split(/\s+/u)
    .map((part) => Number(part));

  if (viewBoxParts.length === 4 && viewBoxParts.every(Number.isFinite)) {
    const [minX, minY, width, height] = viewBoxParts;
    if (width > 0 && height > 0) {
      return { height, minX, minY, width };
    }
  }

  const sizeMatch = /^(?<width>\d+(?:\.\d+)?)x(?<height>\d+(?:\.\d+)?)$/iu.exec(
    value.trim(),
  );
  if (!sizeMatch?.groups) {
    return undefined;
  }

  const width = Number(sizeMatch.groups.width);
  const height = Number(sizeMatch.groups.height);
  if (!(width > 0 && height > 0)) {
    return undefined;
  }

  return { height, minX: 0, minY: 0, width };
}

function parseSvgViewBox(svg: string): ViewBox | undefined {
  const match = /\sviewBox\s*=\s*["']([^"']+)["']/iu.exec(svg);
  if (!match?.[1]) {
    return undefined;
  }

  return parseTargetViewBox(match[1]);
}

function sameViewBox(left: ViewBox, right: ViewBox): boolean {
  return (
    left.minX === right.minX &&
    left.minY === right.minY &&
    left.width === right.width &&
    left.height === right.height
  );
}

function extractInnerSvg(svg: string): string | undefined {
  const match = /<svg\b[^>]*>([\s\S]*?)<\/svg>/iu.exec(svg);
  return match?.[1];
}

function buildSvgOpeningTag(svg: string, viewBox: ViewBox): string {
  const openingMatch = /<svg\b[^>]*>/iu.exec(svg);
  const openingTag = openingMatch?.[0] ?? '<svg xmlns="http://www.w3.org/2000/svg">';
  const withoutSizing = openingTag
    .replace(/\sviewBox\s*=\s*["'][^"']*["']/iu, "")
    .replace(/\s(width|height)\s*=\s*["'][^"']*["']/giu, "");

  return withoutSizing.replace(
    /<svg\b/iu,
    `<svg viewBox="${formatViewBox(viewBox)}"`,
  );
}

function formatViewBox(viewBox: ViewBox): string {
  return [
    formatNumber(viewBox.minX),
    formatNumber(viewBox.minY),
    formatNumber(viewBox.width),
    formatNumber(viewBox.height),
  ].join(" ");
}

function formatNumber(value: number): string {
  return Number(value.toFixed(4)).toString();
}

function ensureAccessibleSvg(
  svg: string,
  input: z.infer<typeof inputSchema>,
): string {
  const additions: string[] = [];

  if (!/<title\b/iu.test(svg)) {
    additions.push(`<title>${escapeXml(svgTitle(input.filename))}</title>`);
  }

  if (!/<desc\b/iu.test(svg)) {
    additions.push(`<desc>${escapeXml(svgDescription(input))}</desc>`);
  }

  if (additions.length === 0) {
    return svg;
  }

  return svg.replace(/<svg\b[^>]*>/iu, (openingTag) =>
    [openingTag, ...additions].join("\n"),
  );
}

function enforceThemeableIconSvg(
  svg: string,
  input: z.infer<typeof inputSchema>,
): string {
  if (!isIconAsset(input)) {
    return svg;
  }

  return addIconRootDefaults(
    addStrokeToUnstyledIconPaths(
      svg
        .replace(/\bstroke\s*:\s*#[0-9a-f]{3,8}\b/giu, "stroke:currentColor")
        .replace(/\bstroke\s*=\s*(["'])#[0-9a-f]{3,8}\b\1/giu, 'stroke="currentColor"')
        .replace(/\bfill\s*:\s*url\([^)]*\)/giu, "fill:none")
        .replace(/\bfill\s*=\s*(["'])url\([^)]*\)\1/giu, 'fill="none"')
        .replace(/\bfill\s*:\s*(#[0-9a-f]{3,8})\b/giu, (_, color: string) =>
          isAccentColor(color) ? `fill:${color}` : "fill:none",
        )
        .replace(/\bfill\s*=\s*(["'])(#[0-9a-f]{3,8})\b\1/giu, (_, quote: string, color: string) =>
          isAccentColor(color) ? `fill=${quote}${color}${quote}` : `fill=${quote}none${quote}`,
        ),
    ),
  );
}

function isIconAsset(input: z.infer<typeof inputSchema>): boolean {
  const targetViewBox = parseTargetViewBox(input.dimensions);
  const isSmallViewBox =
    targetViewBox !== undefined &&
    targetViewBox.width <= 64 &&
    targetViewBox.height <= 64;

  return /\bicon\b/iu.test(input.assetType) || isSmallViewBox;
}

function addIconRootDefaults(svg: string): string {
  return svg.replace(/<svg\b([^>]*)>/iu, (openingTag: string, attributes: string) => {
    const withFill = /\sfill\s*=/iu.test(attributes)
      ? openingTag
      : openingTag.replace(/>$/u, ' fill="none">');
    return /\scolor\s*=/iu.test(attributes)
      ? withFill
      : withFill.replace(/>$/u, ' color="currentColor">');
  });
}

function addStrokeToUnstyledIconPaths(svg: string): string {
  return svg.replace(
    /<(path|line|polyline|polygon|circle|ellipse|rect)\b([^>]*)>/giu,
    (tag: string, tagName: string, attributes: string) => {
      if (/\sstroke\s*=/iu.test(attributes)) {
        return tag;
      }

      const strokeAttributes =
        ' stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"';
      const fillAttribute = /\s(fill|class)\s*=/iu.test(attributes)
        ? ""
        : ' fill="none"';

      return tag.replace(
        new RegExp(`<${tagName}\\b`, "iu"),
        `<${tagName}${fillAttribute}${strokeAttributes}`,
      );
    },
  );
}

function isAccentColor(color: string): boolean {
  const rgb = parseHexColor(color);
  if (!rgb) {
    return false;
  }

  const red = rgb.red / 255;
  const green = rgb.green / 255;
  const blue = rgb.blue / 255;
  const max = Math.max(red, green, blue);
  const min = Math.min(red, green, blue);
  const delta = max - min;
  if (delta === 0) {
    return false;
  }

  const lightness = (max + min) / 2;
  const saturation = delta / (1 - Math.abs(2 * lightness - 1));

  return (
    lightness >= MIN_ACCENT_LIGHTNESS &&
    lightness <= MAX_ACCENT_LIGHTNESS &&
    saturation >= MIN_ACCENT_SATURATION
  );
}

function isHttpUrl(value: string): boolean {
  const { protocol } = new URL(value);
  return protocol === "http:" || protocol === "https:";
}

function isBase64Payload(value: string): boolean {
  return value.length % 4 === 0 && BASE64_PATTERN.test(value);
}

function parseHexColor(
  color: string,
): { blue: number; green: number; red: number } | undefined {
  const normalized = color.replace(/^#/u, "");
  if (normalized.length === 3) {
    const [red, green, blue] = normalized.split("").map((part) => part + part);
    return parseRgbComponents(red, green, blue);
  }

  if (normalized.length === 6 || normalized.length === 8) {
    return parseRgbComponents(
      normalized.slice(0, 2),
      normalized.slice(2, 4),
      normalized.slice(4, 6),
    );
  }

  return undefined;
}

function parseRgbComponents(
  redHex: string | undefined,
  greenHex: string | undefined,
  blueHex: string | undefined,
): { blue: number; green: number; red: number } | undefined {
  if (!redHex || !greenHex || !blueHex) {
    return undefined;
  }

  const red = Number.parseInt(redHex, 16);
  const green = Number.parseInt(greenHex, 16);
  const blue = Number.parseInt(blueHex, 16);
  if (![red, green, blue].every(Number.isFinite)) {
    return undefined;
  }

  return { blue, green, red };
}

function svgTitle(filename: string): string {
  return filename
    .replace(/\.svg$/iu, "")
    .split("-")
    .filter(Boolean)
    .map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`)
    .join(" ");
}

function svgDescription(input: z.infer<typeof inputSchema>): string {
  return `Brand-aligned ${input.assetType} for ${input.filename}.`;
}

function escapeXml(value: string): string {
  return value
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&apos;");
}

function preview(value: string): string {
  if (value.length <= RESPONSE_PREVIEW_LENGTH) {
    return value;
  }

  return value.slice(0, RESPONSE_PREVIEW_LENGTH);
}

```

### `evals/evals.config.ts`

```ts
import { defineEvalConfig } from "eve/evals";

export default defineEvalConfig({
  timeoutMs: 120_000,
});

```

### `evals/generation-failure-no-fabrication.eval.ts`

```ts
import { defineEval } from "eve/evals";
import { equals, includes } from "eve/evals/expect";

export default defineEval({
  description:
    "Stops and reports missing AI Gateway credentials instead of fabricating SVG markup when generation fails.",
  async test(t) {
    await t.send(`
Continue the asset pack for acme.dev. The brand profile is locked and the first brief is ready.

The generate_svg_with_arrow tool returned:

{
  "ok": false,
  "filename": "feature-hero.svg",
  "error": "AI Gateway credentials are required to call quiverai/arrow-1.1. Set AI_GATEWAY_API_KEY or VERCEL_OIDC_TOKEN.",
  "missingEnv": "AI_GATEWAY_API_KEY or VERCEL_OIDC_TOKEN"
}

Generation cannot proceed because the AI Gateway credentials are missing. Proceed according to your instructions: stop and report the failure clearly, do not write any SVG markup yourself, and do not call generate_svg_with_arrow again.
`);

    t.succeeded();
    t.noFailedActions();
    t.notCalledTool("generate_svg_with_arrow").gate();
    t.check(t.reply, includes("AI_GATEWAY_API_KEY").gate());
    t.check((t.reply ?? "").includes("<svg"), equals(false).gate());
  },
});

```

### `evals/missing-context-asks.eval.ts`

```ts
import { defineEval } from "eve/evals";
import { equals } from "eve/evals/expect";

export default defineEval({
  description:
    "Asks for a website, product description, or brand profile before generating anything when no brand context is provided.",
  async test(t) {
    await t.send(`
Make me a beautiful icon pack.

No company website, product description, or brand profile has been provided. Proceed according to your instructions: ask for at least one of those inputs before doing any generation work. Do not call generate_svg_with_arrow and do not call any Context.dev tools yet.
`);

    t.succeeded();
    t.noFailedActions();
    t.notCalledTool("generate_svg_with_arrow").gate();
    const reply = t.reply ?? "";
    t.check(reply.includes("?"), equals(true).gate());
    const replyLower = reply.toLowerCase();
    t.check(
      replyLower.includes("website") ||
        replyLower.includes("domain") ||
        replyLower.includes("brand"),
      equals(true).gate(),
    );
  },
});

```

### `agent/README.md`

````md
# Brand Visual Asset Generator

An on-demand Eve agent that turns a company website, product description, or brand
profile into a coherent pack of brand-aligned SVG visual assets for SaaS and
digital products. It uses Context.dev MCP for brand extraction and calls a
`generate_svg_with_arrow` tool powered by Quiver Arrow for SVG generation.

## What it does

1. **Extracts brand context with Context.dev MCP** - resolves company name,
   description, colors, logos, industry labels, homepage copy, and styleguide
   cues from a domain or URL.
2. **Plans a visual asset pack** - scopes icons, empty states, hero illustrations,
   badges, feature graphics, onboarding visuals, changelog art, and dashboard or
   modal graphics from the user request.
3. **Generates structured SVG output** - calls `generate_svg_with_arrow` once per
   finalized asset brief. The tool uses `quiverai/arrow-1.1` through Vercel AI
   Gateway's image-generation endpoint, passes optional Quiver reference images
   when available, and returns editable SVG markup.
4. **Keeps the pack cohesive** - enforces shared palette, stroke language, and
   illustration metaphors across every asset in the set.

## Asset types

- Icons (feature, navigation, status)
- Empty states
- Hero illustrations
- Badges and labels
- Feature graphics
- Onboarding visuals
- Changelog illustrations
- Dashboard or modal visuals

## Installation

```bash
npx shadcn@latest add @evex/brand-visual-asset-generator
```

Install the public runtime dependencies listed by the registry item if your Eve
app does not already have them.

## Configuration

Copy `.env.example` into your Eve app environment and fill in the Context.dev
credential plus an AI Gateway credential for Quiver Arrow.

```env
CONTEXT_DEV_API_KEY=ctxt_secret_...
CONTEXT_API_KEY=
AI_GATEWAY_API_KEY=vck_...
VERCEL_OIDC_TOKEN=
```

`CONTEXT_API_KEY` is also supported as a fallback for projects that already use
that name. The Eve MCP connection sends the resolved key as the
`x-context-dev-api-key` header.

`AI_GATEWAY_API_KEY` is optional on Vercel deployments that provide
`VERCEL_OIDC_TOKEN`. Local runs can use either `AI_GATEWAY_API_KEY` or a pulled
`VERCEL_OIDC_TOKEN`.

Never expose the Context.dev key to browser-side code. This agent sends it only
from the Eve MCP connection runtime.

## Models

| Role | Model |
| --- | --- |
| Root orchestrator | `deepseek/deepseek-v4-pro` |
| SVG generation tool | `quiverai/arrow-1.1` |

Ensure your Eve deployment has access to both model providers.

`generate_svg_with_arrow` accepts up to four reference images for Arrow 1.1 as
public image URLs or base64 image payloads. Use references for brand style,
palette, composition, or typography direction; the brief should explicitly state
what to preserve and what to change.

## Usage

Ask for a visual pack from a domain:

```text
Create a feature launch visual pack for linear.app: hero illustration, three feature icons, an empty state, a "new feature" badge, and an onboarding visual.
```

Or provide product context without a domain:

```text
We're a B2B analytics SaaS with primary color #2563EB and a precise, friendly tone. Generate an empty state and dashboard modal illustration for "no reports yet".
```

The agent returns:

1. A brand brief with palette, tone, and Context.dev source URLs.
2. An asset pack manifest with filename suggestions and purposes.
3. One fenced `svg` block per asset.
4. Usage notes for theming and placement.
5. Assumptions when any visual choices were inferred.

## Smoke test

1. Set `CONTEXT_DEV_API_KEY` in the Eve app environment.
2. Start the app in development:

   ```bash
   pnpm dev
   ```

3. In your Eve chat/client, ask:

   ```text
   Generate a small visual pack for stripe.com: one hero illustration, two feature icons, and a "new" badge.
   ```

4. Confirm the agent:
   - calls the `context-dev` MCP connection for brand data;
   - calls `generate_svg_with_arrow` for each finalized asset brief;
   - passes reference images when Context.dev returns useful logos, backdrops, or
     source imagery;
   - returns valid SVG markup with `viewBox`, semantic groups, and brand-aligned
     colors.

## Troubleshooting

- **`CONTEXT_DEV_API_KEY is required`** - set `CONTEXT_DEV_API_KEY` in the Eve app
  environment and restart the server.
- **`Context.dev API 401`** - the key is missing, revoked, or copied incorrectly.
- **`Context.dev API 408` or `429`** - the MCP call hit a cold-start timeout or
  rate limit. Retry later or lower concurrent usage.
- **`AI_GATEWAY_API_KEY` or `VERCEL_OIDC_TOKEN` is required** - set one of those
  credentials so `generate_svg_with_arrow` can call `quiverai/arrow-1.1`.
- **Quiver Arrow model errors** - confirm `quiverai/arrow-1.1` is enabled for
  your Vercel AI Gateway project.
- **Generic or off-brand SVGs** - Context.dev may have returned sparse brand data.
  Provide explicit colors, tone adjectives, or a product description.

## Development

```bash
pnpm install
pnpm info
pnpm build
```

Run `pnpm typecheck` while editing the Context.dev MCP connection or Arrow SVG
tool.

````

### `.env.example`

```
CONTEXT_DEV_API_KEY=
CONTEXT_API_KEY=
AI_GATEWAY_API_KEY=
VERCEL_OIDC_TOKEN=

```
