# tasteHQ API

Open dataset of design taste — built for AI agents and humans. No auth, CORS open, JSON.

**Base URL:** `https://taste-hq.vercel.app`

---

## Static JSON endpoints

| Endpoint | Description | Cache |
|----------|-------------|-------|
| `GET /api/styles.json` | Full catalog of every brand entry (lean fields). | `public, max-age=300, s-maxage=3600` |
| `GET /api/styles/<slug>.json` | Full taste-DNA for one brand (tokens, components, render_tokens, live_tokens). | `public, max-age=300, s-maxage=3600` |
| `GET /api/styles/<slug>/design.md` | Raw markdown DESIGN.md export — frontmatter + body sections, LLM-context friendly. | `public, max-age=300, s-maxage=3600` |
| `GET /api/grammar.json` | Graded grammar block per brand (8-axis grammar v2 schema). | `public, max-age=300, s-maxage=3600` |
| `GET /api/graph.json` | Pre-computed similarity graph between brands. | `public, max-age=300, s-maxage=3600` |
| `GET /api/embeddings.json` | Brand corpus used by `POST /api/search`. | `public, max-age=300, s-maxage=3600` |

CORS headers on every API route:

```
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type
```

---

## `GET /api/styles.json`

Catalog of every brand entry. Lean fields only — fetch the per-entry endpoint for full data.

```bash
curl https://taste-hq.vercel.app/api/styles.json
```

```json
{
  "count": 101,
  "version": "1.0",
  "styles": [
    {
      "id": "d469cba4-c448-4a43-a033-883f8bfcdc42",
      "name": "Anthropic",
      "slug": "anthropic",
      "source": "https://styles.refero.design/style/d469cba4-...",
      "official_url": "https://www.anthropic.com",
      "docs_url": "https://docs.anthropic.com",
      "tier": "reference",
      "theme": "light",
      "era": ["1970s research broadsheet", "Swiss Modernism"],
      "mood": ["editorial", "scholarly", "restrained"],
      "density": "medium",
      "signature_move": "Word-level underlines replace color as the sole emphasis mechanism…",
      "voice_persona": "research librarian",
      "voice_score": 75,
      "has_video": true,
      "has_screenshot": true
    }
  ]
}
```

**Tier values:** `reference` (★★★ Golden Set, 5 entries), `verified` (★★ pipeline-extracted), `community` (★ legacy refero data), `unrated`.

---

## `GET /api/styles/<slug>.json`

Full taste-DNA for one brand: tokens, components, body sections (signature_move, era_lineage, doc_voice, anti_patterns), code variants, render_tokens (heuristic), live_tokens (extracted from rendered DOM).

```bash
curl https://taste-hq.vercel.app/api/styles/anthropic.json
```

Returns the full lean+heavy merged record. Use this as the single source of truth for an agent generating UI in a brand's style.

---

## `GET /api/styles/<slug>/design.md`

Raw markdown DESIGN.md export. Direct content of `styles/styles/<slug>.md` — frontmatter + all body sections. Optimized for LLM context windows (paste directly into a prompt).

```bash
curl https://taste-hq.vercel.app/api/styles/anthropic/design.md
```

---

## `POST /api/query` — the taste oracle

The one-call front door. Ask what a brief or URL **resembles**, **which axes matter**, **where a candidate fails**, and the **contract to follow next** — the structured "taste answer" an agent queries *upstream* of generation. Read-only and deterministic; it orchestrates `match` / `score` / `agents-md` and adds no new scoring math. It does not generate anything — the [`/query`](https://taste-hq.vercel.app/query) page and the cinematic-scroll skill consume its contract.

**Request body**

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `query` | string | yes | A plain-English brief **or** an `http(s)` URL. A URL is vectorized into its live 30-axis grammar (your own brand); text is matched against the catalog. |
| `candidate_url` | string | no | If present, the answer also includes `failures` — the score + ranked fixes for this URL against the resolved target ("where it fails"). |

**Response**

| Field | Notes |
|-------|-------|
| `query_kind` | `"brief"` or `"url"` |
| `resolved_target` | What the query resolved to (`{slug, source, name}`; `slug` is `null` for a URL query). A brief resolves to its top match. |
| `resembles` | Ranked references that apply (`{slug, name, score, why, borrow}`). |
| `axes` | The 30-axis grammar **ranked by weight** — `[{axis, value, weight}]`. Literally "which axes matter." |
| `failures` | `null` unless `candidate_url` given; else `{score, verdict, confidence, fixes[]}`. |
| `contract` | The `AGENTS.md` to follow next — `{agents_md_url, agents_md}` (inline). |
| `audit` | `{judge, grammar_version, weights_version, timestamp}`. |

```bash
# Ask what a brief resembles, get axes + a ready contract
curl -s -X POST https://taste-hq.vercel.app/api/query \
  -H "Content-Type: application/json" \
  -d '{"query": "restrained editorial fintech"}'

# Tailor to YOUR brand (vectorize a URL) and grade a candidate in one call
curl -s -X POST https://taste-hq.vercel.app/api/query \
  -H "Content-Type: application/json" \
  -d '{"query": "https://your-brand.com", "candidate_url": "https://your-preview.vercel.app"}'
```

---

## `POST /api/search`

Keyword-expansion BM25-style semantic search across the brand catalog.

**Request body**

| Field | Type | Required | Default | Notes |
|-------|------|----------|---------|-------|
| `q` | string | yes | — | Natural-language query. Synonym-expanded against an aesthetic / era / persona map. |
| `limit` | integer | no | `10` | Number of results (clamped to `1..100`). Non-integer values return 400. |

```bash
curl -X POST https://taste-hq.vercel.app/api/search \
  -H "Content-Type: application/json" \
  -d '{"q": "warm research aesthetic", "limit": 5}'
```

**Response (200)**

```json
{
  "results": [
    {
      "slug": "anthropic",
      "name": "Anthropic",
      "signature_move": "Word-level underlines replace color…",
      "era": ["1970s research broadsheet", "Swiss Modernism"],
      "mood": ["editorial", "scholarly", "restrained"],
      "persona": "research librarian",
      "score": 0.0421,
      "match_type": "vibe"
    }
  ],
  "query": "warm research aesthetic",
  "expanded_terms": ["warm", "earthy", "research", "academic", "..."],
  "total": 5,
  "version": "v1-keyword-expansion"
}
```

**Status codes**

| Status | Condition |
|--------|-----------|
| `200` | Match returned (may be empty `results`). |
| `400` | Invalid JSON, missing `q`, or non-integer `limit`. |

**Cache:** `public, max-age=60, s-maxage=300`.

---

## `POST /api/extract`

URL → brand-entry starter. Fetches the page, runs a stdlib HTML parser, extracts a candidate palette, voice hint, and signature-move draft. If the URL is already in the catalog (matched on `official_url`), returns the existing entry under `source: "catalog"`.

**Request body**

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `url` | string | yes | Must be `http://` or `https://`. Private / localhost / link-local IPs are rejected (SSRF guard). |

```bash
curl -X POST https://taste-hq.vercel.app/api/extract \
  -H "Content-Type: application/json" \
  -d '{"url": "https://anthropic.com"}'
```

**Response (200)** — newly extracted candidate

```json
{
  "url": "https://anthropic.com",
  "source": "extracted",
  "candidate": {
    "name": "Anthropic",
    "slug": "anthropic",
    "tagline": "Anthropic is an AI safety company…",
    "favicon_url": "https://anthropic.com/favicon.ico",
    "preview_image": "https://anthropic.com/og.png",
    "starter_palette": ["#cc7860", "#1a1a1a", "#fefbf6"],
    "voice_hint": "research-org gravitas, terse declarative",
    "signature_move_draft": "stacked short declaratives: '…' / '…' — terse authority; 3-color palette anchors visual identity",
    "headings_sample": ["Claude is here.", "Build with Claude.", "…"],
    "next_steps": "Run `python tools/extract_live_tokens.py --url https://anthropic.com` locally for full pipeline; open a PR with the new entry."
  }
}
```

**Response (200)** — already in catalog

```json
{
  "url": "https://stripe.com",
  "source": "catalog",
  "candidate": {
    "name": "Stripe",
    "slug": "stripe",
    "catalog_entry": { "...": "full styles.json entry" },
    "next_steps": "This brand is already in the catalog as 'stripe'. Open the gallery and search by name to see the full entry."
  }
}
```

**Status codes**

| Status | Condition |
|--------|-----------|
| `200` | Extraction succeeded (or catalog hit). |
| `400` | Invalid JSON, missing `url`, or URL fails validation (scheme, hostname, private IP). |
| `502` | Upstream HTTP error fetching the page. |
| `500` | Unexpected extraction error. |

**Cache:** `no-store` — every extract call hits the live page; caching responses (and errors) at the edge would serve stale results.

---

## `POST /api/compose`

Deterministic hybrid synthesizer — merges grammar axes from N graded brands into one synthesized brand entry. No LLM call; the `signature_move` is templated from the merged grammar.

**Request body**

| Field | Type | Required | Default | Notes |
|-------|------|----------|---------|-------|
| `sources` | object | yes | — | Map of axis category → source brand slug. Categories: `surface`, `palette`, `type`, `emphasis`, `whitespace`, `voice`, `motion`, `imagery`. Every slug must exist in `api/grammar.json` and be graded. |
| `name` | string | no | autogenerated | Friendly name for the hybrid. Defaults to `"<Source1> × <Source2> hybrid"`. |
| `output_format` | string | no | `"json"` | `"json"` (default) returns the synthesized entry. `"design_md"` returns a rendered Markdown DESIGN.md document. |

```bash
curl -X POST https://taste-hq.vercel.app/api/compose \
  -H "Content-Type: application/json" \
  -d '{
    "sources": {
      "surface": "stripe",
      "palette": "anthropic",
      "voice": "anthropic",
      "whitespace": "acne-studios"
    },
    "name": "Stripe × Anthropic × Acne hybrid"
  }'
```

**Response (200, `output_format: "json"`)**

```json
{
  "name": "Stripe × Anthropic × Acne hybrid",
  "slug": "stripe-anthropic-acne-hybrid",
  "tier": "unrated",
  "provenance": "composed",
  "signature_move": "A taste hybrid of a warm cream ground, museum-grade negative space, and a research-librarian's restraint, with monochrome accent strategy.",
  "grammar": {
    "surface": { "ground": "cream", "border": "…" },
    "palette": { "strategy": "mono", "...": "..." },
    "voice": { "archetype": "librarian", "...": "..." },
    "whitespace": { "discipline": "extreme", "...": "..." }
  },
  "_compose": {
    "sources": { "surface": "stripe", "palette": "anthropic", "voice": "anthropic", "whitespace": "acne-studios" },
    "source_quotes": [
      { "slug": "stripe", "name": "Stripe", "signature_move": "…" },
      { "slug": "anthropic", "name": "Anthropic", "signature_move": "…" },
      { "slug": "acne-studios", "name": "Acne Studios", "signature_move": "…" }
    ],
    "axis_count": 14,
    "version": "v1-templated"
  }
}
```

**Response (200, `output_format: "design_md"`)**

Returns `Content-Type: text/markdown; charset=utf-8` — a frontmatter + body Markdown document ready to paste into an LLM context.

**Status codes**

| Status | Condition |
|--------|-----------|
| `200` | Compose succeeded. |
| `400` | Invalid JSON, empty `sources`, unknown axis category, unknown brand slug, or brand has no graded grammar. |
| `500` | Unexpected compose error. |

**Cache:** `public, max-age=60, s-maxage=300`.

---

## `POST /api/log`

Anonymized event ingestion for the tasteHQ closed-loop scoring scaffold. No auth. No user content stored. v0 writes JSONL to ephemeral `/tmp` on the function instance — production will move to Vercel KV / Blob.

**Request body**

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `event` | string | yes | One of `brand_pull`, `compose`, `search`, `extract`, `rate_outcome`. |
| `client` | string | no | One of `mcp`, `cli`, `web`, `agent`, `unknown` (default `unknown`). |
| `ts` | string | no | ISO-8601 timestamp ≤ 64 chars. Server stamps current UTC if absent. |
| `brand_slug` | string | conditional | Required for `brand_pull` and `rate_outcome`. Slug pattern: `[a-z0-9][a-z0-9-]{0,80}`. |
| `rating` | number | conditional | Required for `rate_outcome`. Range `1..10`. |
| `sources` | object | optional | For `compose` events. Only string slug values matching the slug pattern are stored. |

```bash
curl -X POST https://taste-hq.vercel.app/api/log \
  -H "Content-Type: application/json" \
  -d '{"event": "brand_pull", "brand_slug": "stripe", "client": "mcp"}'
```

**Response (204)** — empty body.

**Status codes**

| Status | Condition |
|--------|-----------|
| `204` | Accepted (or silently dropped if the filesystem is read-only). |
| `400` | Invalid JSON, unknown `event`, unknown `client`, invalid `brand_slug`, or out-of-range `rating`. |

**Cache:** `no-store`.

---

## `POST /api/score`

Score a live URL against a target taste, returning a weighted match score with a per-axis breakdown and a prescriptive fix-list. The target is one of three (in precedence order):

- **`target_brand`** — a catalog slug (full 30-axis comparison against a published brand).
- **`target_url`** — *any* URL. Its grammar is extracted live, so you can judge against **your own brand** (or any reference) without it being in the catalog. Only axes measurable on the target are scored.
- **`target_grammar`** — a grammar vector passed directly (nested `{cat:{axis:val}}` or flat `{"cat.axis":val}`). The reproducible verify path: an `AGENTS.md` contract pins its own grammar and posts it back, so the score never depends on re-fetching the brand site.
- **`brief`** — a plain-English description; only axes the brief mentions are scored.

**Request body**

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `url` | string | yes | Candidate URL to score. Fetched server-side via the extract pipeline. |
| `target_brand` | string | one target required | Catalog slug, e.g. `"stripe"`. Returns 404 if unknown. |
| `target_url` | string | one target required | Any brand URL to match, e.g. `"https://your-brand.com"`. Extracted live; 502 if unreachable. |
| `target_grammar` | object | one target required | A grammar vector (nested or flat dotted-key). Precedence is `target_brand` > `target_url` > `target_grammar` > `brief`. |
| `brief` | string | one target required | Plain-English design description. Must parse at least one axis or returns 400. |

```bash
# Judge against your own brand — the catalog isn't required
curl -X POST https://taste-hq.vercel.app/api/score \
  -H "Content-Type: application/json" \
  -d '{"url": "https://my-ai-generated-page.vercel.app", "target_url": "https://my-brand.com"}'

# …or against a catalog brand, or a brief
curl -X POST https://taste-hq.vercel.app/api/score \
  -H "Content-Type: application/json" \
  -d '{"url": "https://stripe.com", "brief": "white background, minimal, monochrome, terse copy"}'
```

**Response (200)**

```json
{
  "score": 82,
  "verdict": "pass",
  "summary": "Strong ground color and color strategy. weakened by mismatched whitespace generosity.",
  "strongest": "surface.ground",
  "weakest": "whitespace.discipline",
  "failure_mode": "Whitespace generosity reads as 'medium' rather than the 'generous' the brief calls for",
  "next_action": "Increase whitespace generosity to generous",
  "fixes": [
    {
      "axis": "whitespace.discipline",
      "label": "whitespace generosity",
      "current": "medium",
      "target": "generous",
      "action": "Increase whitespace generosity to generous",
      "score": 0.5,
      "weight": 0.686
    }
  ],
  "axes": [
    {
      "key": "surface.ground",
      "brief_target": "white",
      "predicted": "white",
      "score": 1.0,
      "status": "pass"
    },
    {
      "key": "whitespace.discipline",
      "brief_target": "generous",
      "predicted": "medium",
      "score": 0.5,
      "status": "fail",
      "note": "Brief calls for whitespace generosity 'generous'; page shows 'medium'"
    }
  ],
  "brief_axes_matched": 4,
  "brief_axes_scored": 4,
  "url": "https://stripe.com",
  "brief_excerpt": "white background, minimal, monochrome, terse copy"
}
```

**Verdict values:** `pass` (score ≥ 80), `revise` (55–79), `reject` (< 55).

`fixes` is the prescriptive list — every failing axis with the concrete change to make, ranked by impact (catalog weight). It's what lets the judge **drive the next generation iteration** (generate → judge → regenerate), not just grade. `next_action`/`failure_mode` remain for the single weakest axis. The response echoes `target_source` (`brand` / `reference` / `grammar` / `brief`); for `reference` and `grammar` modes it also carries `target_url` / `target_axes` (how many axes the target itself yielded).

**Status codes**

| Status | Condition |
|--------|-----------|
| `200` | Scoring complete. |
| `400` | Missing `url`, or no target (`target_brand` / `target_url` / `brief`), or brief too vague. |
| `404` | `target_brand` is not a known catalog slug. |
| `502` | The candidate `url` or the `target_url` could not be reached or parsed. |
| `500` | Internal scoring error. |

**Cache:** `no-store`.

---

## `POST /api/vectorize`

Extract the **30-axis grammar vector** for *any* URL — the "ingest any brand into a vector" primitive. Same extraction + projection the judge runs internally, exposed on its own so you can turn your own brand (or any reference) into a grammar contract without it being in the curated catalog. Pairs with `POST /api/score` `{ "url": candidate, "target_url": your_brand }`.

**Request body**

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `url` | string | yes | Brand URL to vectorize. SSRF-guarded server-side fetch. |

```bash
curl -X POST https://taste-hq.vercel.app/api/vectorize \
  -H "Content-Type: application/json" \
  -d '{"url": "https://your-brand.com"}'
```

**Response (200)**

```json
{
  "url": "https://your-brand.com",
  "grammar": { "surface": { "ground": "white", "radius": "4-6px" }, "palette": { "contrast": "extreme" } },
  "grammar_flat": { "surface.ground": "white", "surface.radius": "4-6px", "palette.contrast": "extreme" },
  "render_tokens": { "bg": "#ffffff", "fg": "#0a2540", "accent": "#635bff" },
  "coverage": {
    "axes_total": 30, "axes_extracted": 22, "axes_measured": 20, "axes_recovered": 2,
    "statement": "Extracted 22 of 30 axes (20 measured, 2 inferred)."
  },
  "grammar_version": "2.0.0"
}
```

**Status codes:** `200` success · `400` missing `url` · `502` unreachable/unparseable · `500` internal error.

**Cache:** `no-store`.

---

## `POST /api/agents-md`

Generate a **self-contained `AGENTS.md` taste contract from any URL** — tailor an agent to *your own* brand without it being in the catalog. Extracts the live grammar, embeds it inline (so the contract needs no catalog lookup), and wires the verify block to score against `target_url` (live) plus the pinned `target_grammar` (reproducible CI). Returns `text/markdown`.

(For the 101 catalog brands, use the slug form `GET /api/styles/{slug}/agents.md` instead.)

**Request body**

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `url` | string | yes | Your brand's URL. SSRF-guarded server-side fetch. |

```bash
# CLI (recommended)
tastehq init --url https://your-brand.com

# …or the raw endpoint
curl -s -X POST https://taste-hq.vercel.app/api/agents-md \
  -H "Content-Type: application/json" \
  -d '{"url": "https://your-brand.com"}' > AGENTS.md
```

Response is the rendered Markdown contract (frontmatter with an inline `grammar:` block, `required_axes`, and a `verify:` block). **Status:** `200` · `400` missing `url` · `502` unreachable/unparseable.

**Cache:** `no-store`.

---

## `GET /styles/screenshots/:slug.webp`

Static homepage screenshot, 1280×1600, WebP, ~50–200KB.

## `GET /styles/videos/:slug.mp4`

Animated scroll-recording, H.264 baseline, ~1–2MB, no audio.

---

## Agent integration recipes

### Claude Code / Cursor / Copilot — pull a brand into a prompt

```bash
curl -s https://taste-hq.vercel.app/api/styles/stripe/design.md | head -200
```

Then ask the agent: "Build a pricing page in Stripe's style using the design tokens above."

### Programmatic taste retrieval

```python
import urllib.request, json
catalog = json.loads(urllib.request.urlopen("https://taste-hq.vercel.app/api/styles.json").read())
editorial_brands = [s for s in catalog["styles"] if "editorial" in s["mood"]]
# 32 brands as of v1.0
```

### Search → compose pipeline (Python)

```python
import urllib.request, json

def post(path, body):
    req = urllib.request.Request(
        f"https://taste-hq.vercel.app{path}",
        data=json.dumps(body).encode(),
        headers={"Content-Type": "application/json"},
    )
    return json.loads(urllib.request.urlopen(req).read())

top = post("/api/search", {"q": "editorial luxury", "limit": 3})["results"]
hybrid = post("/api/compose", {
    "sources": {"voice": top[0]["slug"], "surface": top[1]["slug"]},
})
print(hybrid["signature_move"])
```

### Schema reference

Frontmatter schema, controlled vocabularies (mood / density / tier), failure taxonomy:
[docs/superpowers/specs/2026-05-03-styles-gallery-enrichment-design.md](https://github.com/MustBeSimo/tasteHQ/blob/main/docs/superpowers/specs/2026-05-03-styles-gallery-enrichment-design.md)

---

## Versioning

- `version: "1.0"` in catalog response
- `api_version: "1.0"` in per-entry response

Breaking changes get a major bump and a new `/api/v2/` namespace. Additive fields don't bump.

## Rate limits

None. Cache headers vary by endpoint — see each endpoint section above.

## License

Open. Attribution appreciated.
