Lossless Changelog
component

The Orchestrator Learns to Read a Website

A new brand_fetch module drives a Claude tool-use loop over a firm's homepage, and two endpoints expose it. The HTTP surface keeps growing; agents stay untouched.

Michael StatonMusic Editoraugmented with Claude Code (Opus 4.7)
feat(server): brand_fetch module + /actions/fetch-brand|/save-brand

A new src/server/brand_fetch.py runs a Claude tool-use loop with two
tools: fetch_url(url) for HTML/CSS retrieval (60KB body cap, dedupes
URLs within a session), and submit_brand_config with a 19-field schema
covering identity, light + dark colors, fonts, and logo URLs. Default
model is Haiku 4.5 (claude-haiku-4-5-20251001), MEMOPOP_BRAND_MODEL env
override available. 8-iteration cap, ~10–20s wall time, ~$0.05–0.15
per call.

The shape pipeline normalizes Claude's color guesses to #rrggbb (handles
missing leading #, uppercase, 3-char shorthand expansion), maps the flat
tool output into the nested brand-config YAML, and recursively drops
empty strings and empty dicts so the file stays clean.

* POST /actions/fetch-brand { firm, url } → { firm, config }
  Runs the Claude call in a worker thread via asyncio.to_thread so the
  FastAPI loop stays responsive to concurrent /memos SSE streams. Returns
  the structured dict — does NOT write to disk. The caller (the desktop
  client) shows it to the user for review.

* POST /actions/save-brand { firm, config } → { firm, path }
  Writes the user-confirmed config to io/{firm}/configs/brand-{firm}-
  config.yaml. Deep-merges with any existing file. Special case:
  company.conventional_name from the firm-creation scaffold is preserved
  even if the new config doesn't carry that field. Brand setup must
  never clobber the name the user typed during firm creation.

+4 tests (hex normalization, empty-field stripping, full shape mapping,
conventional_name preservation invariant). 22 total passing in 0.36s.
No agent code touched. The CLI is unaffected. Pairs with memopop-ai
changelog 2026-05-01_01 in apps/memopop-native/changelog/ which ships
the consuming UI.

Changelog - 2026-05-01_04

The Orchestrator Learns to Read a Website

The HTTP surface that started as "wrap generate_memo over localhost:8765" three days ago now also reads firm websites. A new brand_fetch module drives a Claude tool-use loop that lands on a firm's homepage, follows links to /about or a stylesheet if it needs more signal, and returns a populated brand-config dict. Two endpoints expose it: /actions/fetch-brand for the read, /actions/save-brand for the write. The desktop client shows the result to a user, lets them edit every field, then commits the user-confirmed version to disk.

Zero changes to any agent. generate_memo() is identical. python -m src.main still works exactly as it did. This is purely an API surface that consumes the orchestrator's existing infrastructure (Anthropic SDK, httpx, the firm-scoped IO conventions) for a new purpose.


Why

The desktop client's onboarding spec scoped the firm-creation modal tightly: capture company.conventional_name only, defer everything else. The "everything else" — colors, fonts, logos, tagline, official name — is what makes a memo look like a firm's memo. Asking for those fields up front would lose 90% of the audience; asking for them later, after the user has run a memo and wants to share it, has different odds.

The spec named the version we'd ship: an agent that reads the firm's website. That work belongs in the orchestrator, not the desktop client, for the same reasons memo generation does:

  1. The Claude SDK already lives here. langchain-anthropic and the underlying anthropic package are already deps; ANTHROPIC_API_KEY is already required for memo generation. Adding a second use case is zero new secret management.

  2. The HTTP fetching primitives already live here. httpx, beautifulsoup4 — already deps used by the research and citation agents.

  3. Any non-desktop client gets brand fetch for free. A notebook, a curl one-liner, a future iOS app. It's not a memopop-native feature.

The desktop client gets a thin two-endpoint integration (route through forward_to_sidecar, render a modal). The work happens here.


What Landed

src/server/brand_fetch.py — Claude reads, we shape

Two public functions:

fetch_brand_from_url(firm, url) -> dict runs a Claude tool-use loop. Two tools are exposed:

ToolDescription
fetch_url(url)Server-side httpx.get with a 60KB body cap. Returns text. Dedupes — the loop refuses to fetch the same URL twice within a session.
submit_brand_config()The "I'm done" call. 19 fields covering identity, light + dark colors, fonts, logo URLs, and a free-form confidence_notes string.

The loop runs at most MAX_ITERATIONS = 8 cycles. In testing, Claude finishes in 3–4 cycles for a typical firm:

1. fetch_url(homepage)        → catches title, og:image, theme-color, linked CSS hrefs
2. fetch_url(/about)          → catches official name, tagline
3. fetch_url(main.css)        → confirms primary/accent colors
4. submit_brand_config(...)   → returns the 19-field structured config

Default model is claude-haiku-4-5-20251001. Override via the MEMOPOP_BRAND_MODEL env var if a firm's site is unstructured enough to need Sonnet's judgment.

After Claude submits, the result passes through _shape_brand_config():

  • _normalize_hex() coerces 5b21b6 / #5b21b6 / #5B21B6 / #abc all into #rrggbb form. Rejects garbage to None (then dropped).

  • The flat tool output maps to the nested YAML schema (company, colors, colors_dark, fonts, logo, _meta).

  • _drop_empty() recursively prunes empty strings, None, and empty dicts so the YAML stays clean — half-populated fields don't pollute the file.

  • Claude's confidence_notes paragraph rides along in _meta.confidence_notes for the UI to surface.

save_brand_config(firm, config) -> Path writes the user-confirmed config to io/{firm}/configs/brand-{firm}-config.yaml. Deep-merges with any existing file via _deep_merge(). Special-case guarantee:

PYTHON
existing_conv = (existing.get("company", {}) or {}).get("conventional_name")
if existing_conv and not merged.get("company", {}).get("conventional_name"):
    merged.setdefault("company", {})["conventional_name"] = existing_conv

The firm-creation scaffold writes conventional_name. Brand setup runs later and must never clobber it. The deep-merge handles the general case; this catch handles the specific failure mode where Claude returns no conventional_name value at all (it usually doesn't — the official name is the website-surfaced one, not the conversational one).

src/server/app.py — two endpoints

PYTHON
@app.post("/actions/fetch-brand")
async def fetch_brand(request: FetchBrandRequest) -> dict:
    config = await asyncio.to_thread(
        fetch_brand_from_url, request.firm.strip(), request.url.strip()
    )
    return {"firm": request.firm.strip(), "config": config}

The blocking Claude call runs in a worker thread via asyncio.to_thread — same pattern the memo runner uses. A long brand fetch (~15s) doesn't starve concurrent SSE streams from a memo run.

PYTHON
@app.post("/actions/save-brand")
async def save_brand(request: SaveBrandRequest) -> dict:
    path = save_brand_config(request.firm.strip(), request.config)
    return {"firm": request.firm.strip(), "path": str(path)}

Two new Pydantic models (FetchBrandRequest, SaveBrandRequest) added inline in app.py — small enough to not justify a new file. BaseModel import added to app.py's imports.

Tests — 4 new, 22 total

TestWhat it pins down
test_normalize_hex_handles_real_world_inputs#5b21b6, 5b21b6, #5B21B6, #abc → all → normalized; "not a color", "", None, 42 → all → None.
test_drop_empty_recursively_strips_blanksEmpty strings, None, empty dicts never reach the YAML; nested empty dicts are also stripped.
test_shape_brand_config_normalizes_and_drops_emptiesEnd-to-end: Claude's flat output → nested YAML, with hex normalization, alt fallback (empty alt → company name), and empty-field dropping. Verified each section of the output.
test_save_brand_config_preserves_conventional_nameExisting company.conventional_name survives a save that doesn't carry that field. The other new fields merge in cleanly.

None of the four tests make a real Claude API call — they exercise the pure helpers (_normalize_hex, _drop_empty, _shape_brand_config) and the file-side save_brand_config directly. The Anthropic SDK call is integration-tested by the desktop client running it for real, not by mocking the API in pytest.

TEXT
$ .venv/bin/python -m pytest tests/ -v
======================== 22 passed in 0.36s ========================

Design Decisions

Tool-use over single-shot extraction. Considered a simpler design where the server fetches the homepage once + pre-parses meta tags + sends one Claude prompt. Rejected because Claude's judgment about which secondary pages to fetch is genuinely useful — sometimes the tagline is on /about, sometimes the homepage hero, sometimes only in the page <title>. Letting Claude orchestrate 3–4 fetches with deduplication produces materially better results than guessing pre-fetch what to send. Cost difference at Haiku pricing is small.

Haiku 4.5, not Sonnet. Brand extraction is a low-judgment task: read HTML, find the primary color, return it. Haiku is fast (10–20s end to end vs Sonnet's 25–40s) and cheap (~$0.05–0.15 per call vs ~$0.40–0.80). If a particular firm's site is unusually unstructured (heavy SPA, image-only branding), the MEMOPOP_BRAND_MODEL env override is one line away. We're not trying to be clever about model selection; we're starting cheap.

Two endpoints, not one. Fetch returns the structured config without writing. Save writes the user-confirmed version. The split is the entire feature — brand managers must have final say on every value before it lands on disk. A combined "fetch and save" endpoint would skip the human-in-the-loop step that makes the feature defensible to brand-conscious firms.

Worker thread, not async-native. The Anthropic Python SDK has both sync and async clients. We use the sync client in a to_thread. Reason: the agents in src/agents/* use the sync client extensively, the loop pattern with client.messages.create is well-trodden, and asyncio.to_thread is one line. An async-native version is a future micro-optimization, not a correctness improvement.

Deep-merge with explicit conventional_name guard. _deep_merge handles the general case where new YAML overlays existing YAML. The explicit catch for company.conventional_name is a belt-and-suspenders for the specific failure mode where (a) the firm-creation scaffold wrote conventional_name and (b) brand setup's Claude call returned no name at all (uncommon but seen). Without the guard, the deep-merge would still preserve conventional_name (it's in existing.company.conventional_name, the new config has no overriding conventional_name, deep-merge keeps the existing value). The guard makes the invariant explicit and testable.

No mocking of Anthropic in tests. The four new tests cover the pure shaping/normalization/saving helpers — the parts that are deterministic and pure. The actual fetch_brand_from_url Claude call is not unit-tested because mocking the Anthropic tool-use API surface is brittle (model behavior changes, tool-call shapes evolve), and the integration is tested by the desktop client running it on real firms. We pay for honesty: the tests cover the parts where we control the behavior; the integration is observed end-to-end.


Verification

TEXT
$ .venv/bin/python -m pytest tests/ -v
======================== 22 passed in 0.36s ========================

$ .venv/bin/python -c "from src.server.app import app; \
    print([r.path for r in app.routes if hasattr(r, 'methods')])"
['/openapi.json', '/docs', '/docs/oauth2-redirect', '/redoc',
 '/healthz', '/actions/fetch-brand', '/actions/save-brand',
 '/memos', '/memos', '/memos/{job_id}', '/memos/{job_id}/events',
 '/memos/{job_id}/artifacts', '/memos/{job_id}/artifacts/{path:path}']

Both new routes register cleanly. End-to-end manual run from the desktop client tonight — real firm URL, ~15s wall time, ~$0.07 charged to the orchestrator's Anthropic key, populated colors + fonts + logo + tagline, every field correctly editable, saved YAML deep-merged with the existing conventional_name from firm creation.


Files Changed

FileChange
src/server/brand_fetch.pyCreated (~280 lines) — fetch_brand_from_url, save_brand_config, hex normalization, shape pipeline, deep-merge with conventional_name guard.
src/server/app.pyAdded BaseModel import. Added FetchBrandRequest / SaveBrandRequest. Added /actions/fetch-brand and /actions/save-brand endpoints. Imported the two new functions from brand_fetch.
tests/test_server.py+4 tests: hex normalization, drop_empty recursion, full shape mapping, conventional_name preservation.

No existing files were modified beyond the additive imports and route registration in app.py. No agent code was touched.


What's Next

Three orchestrator-side follow-ups, none urgent:

  1. Cache fetched brand HTML for re-fetches. A user re-running brand setup on the same firm hits Anthropic again from scratch. Caching the most recent fetch_url results per firm in io/{firm}/cache/brand-fetch/ (with a TTL) would let "re-fetch with edits" be free for the second pass. Useful when iterating on Claude's prompt or schema, less useful for end users.

  2. Auto-derived dark-mode colors. Claude's dark-mode guesses are weakest — most websites only advertise their light theme. A post-shape step that, when dark-mode fields are empty, computes a sensible inversion (swap text↔background, retain accents) would land closer to "polished" without the user filling them in. ~30 lines in _shape_brand_config.

  3. A GET /actions/load-brand endpoint. The save side exists; the load side doesn't. Right now if the desktop client wants to show the on-disk brand config (for a re-edit flow), it'd have to read the YAML itself or invent a side channel. A GET /actions/load-brand?firm={firm} returning the parsed config is one small handler. Pairs with the desktop's "load existing" follow-up.

The spec called brand setup the most-deferred-but-most-important polish step. It's no longer deferred. The next round of fit-and-finish is whatever surfaces when a non-Mike user types a real URL — we'll see what lands.