Public Libraries — MCP, JSON-LD & Social Discovery PRD
Status: Phase 1 thinned + Phase 2 directory/search/MCP shipped (live user_profiles RLS; public_library_index still optional). Per-library branded OG deferred.
Updated: August 2026
Owner: Product / Platform
Surfaces: Canonical /l/{token}, owner /library (noindex SPA; anonymous visitors see a listing), debug directory /debug/libraries, site search, public MCP POST /mcp, social crawlers
Executive summary
Owners already save a library title and library context for AI (description) on the public share view. Those fields stay on-page only. Shared libraries still use opaque query URLs (/library?token=…), a generic Open Graph card, no per-library JSON-LD, and they are not listed in site search, sitemap, or MCP.
This PRD makes every share-enabled library a first-class, citeable object:
- Title + description populate social metadata and every indexable store.
- Public libraries appear in the site directory, site search, and public MCP.
- Agents receive a canonical URL plus JSON-LD and markdown.
- Share cards use a stable OG image (
/og-library.png). Per-library branded PNGs are deferred.
Do not implement Phase 2 from this file until it is prioritized. Phase 1 keeps always-200 /l/{token} with title/description/JSON-LD/markdown and /sitemap-libraries.xml. Share cards use static /og-library.png until branded per-library OG is re-enabled. Saving a library does not rebuild the static site. Adjacent work stays in ROADMAP SHARED LIBS (directory + username URLs) and ROADMAP MULTILIBRARY (schema + AI context). Authenticated owner MCP (/mcp/auth) stays in Authenticated MCP Library PRD.
Problem
| Today | Gap |
|---|---|
Title / description saved on user_profiles | Not copied into og:*, Twitter, JSON-LD, sitemap, or search |
Share URL /library?token={uuid} | Query-string identity; page is client-only + force-static |
src/app/library/layout.tsx | Static title “My Library | PATTTTERNS”, image /og-library.png |
public/robots.txt | Disallow: /library — crawlers must not index it |
public/search-index.json | Patterns / flows / components only |
| Public MCP | Pattern + component tools only |
| JSON-LD | Site-wide WebSite + catalog CollectionPage in root layout |
| Markdown negotiation | Does not run on /library |
Social crawlers and agents read the first HTML response, not the client-side Supabase fetch. Unique on-page titles therefore never appear in Slack, LinkedIn, iMessage, or MCP citations.
Goals
- Owner-authored title and description are the source of truth for social tags, JSON-LD, markdown, search, MCP, and OG text.
- Every public library has a stable, unique, path-based URL agents can share.
- Public libraries are listed on the site, in search, and via public MCP (read-only).
- Share cards use static
/og-library.png(per-library branded PNG deferred). - Existing
/library?token=links keep working.
Non-goals
- Authenticated MCP write tools (
save_pattern, ownerget_library) — Authenticated MCP Library PRD. - Multi-library schema (
libraries+bookmark_on_library) — ROADMAP MULTILIBRARY Phase 2. This feature ships on currentuser_profiles+bookmarks. - Username / slug SEO (
/library/[username]/[slug]) as a blocker. Tracked as a later alias. - Forcing title/description before share is enabled (fallbacks exist; empty copy is still indexed).
- View counters, pricing, or “fork this library”.
Current data (already shipped)
type LibraryProfile = {
user_id: string;
library_title: string | null; // ≤ 60 chars, owner-editable
library_description: string | null; // ≤ 500 chars, “LIBRARY CONTEXT FOR AI”
share_token: string | null; // UUID
share_enabled: boolean;
public_display_name: string | null;
public_avatar_url: string | null;
public_show_author: boolean;
};
Public read today: getPublicProfile(token) + getPublicBookmarks(user_id) via Supabase REST + RLS (share_enabled = true).
URL strategy vs social profiling
Documented constraint (ROADMAP MULTILIBRARY non-goal, ROADMAP SHARED LIBS Feature 4): libraries do not have unique SEO paths. Live share links look like:
https://patttterns.com/library?token=b0160c5b-179c-418c-8380-10cd3d8b09c6
Does this block social / MCP / JSON-LD?
Query-string-only identity blocks crawler-correct social cards and agent citation. It does not require username slugs.
| Concern | Query ?token= only | Path /l/{token} | Username /library/{handle} |
|---|---|---|---|
Unique @id / og:url | Weak (one static /library HTML) | Yes | Yes |
generateMetadata / edge rewrite | No — layout is static | Yes | Yes |
| Sitemap + robots allow | Awkward; currently disallowed | Yes | Yes |
| MCP resource URI | Fragile | Yes | Yes |
| Needs username uniqueness | No | No | Yes (not built) |
| Needs multi-library slugs | No | No | Later |
Decision for this PRD: introduce a canonical path now; keep the token as the identifier; defer pretty usernames.
| Role | URL |
|---|---|
| Canonical (Google, agents, OG, JSON-LD, sitemap, MCP) | https://patttterns.com/l/{share_token} |
| Interactive SPA (noindex; not canonical) | https://patttterns.com/library?token={share_token} |
| Owner workspace | /library (authenticated, noindex, static CDN) |
| Public directory | Anonymous /library listing; full directory UI at /debug/libraries only |
| Future alias (out of scope) | /library/{username} → same resource via sameAs / 308 |
/l/ is used so /library/[username] can still land later without colliding with tokens.
Share button and clipboard copy the canonical URL. Regenerating share_token changes the canonical URL (same as today). Do not 308 /library?token= → /l/{token}: that would either loop with the interactive CTA or send Google onto a Disallow/noindex SPA.
Metadata contract
One mapping. No separate “SEO title” field.
| Source | Fallback | Written to |
|---|---|---|
library_title | "My Pattern Library" | title, og:title, twitter:title, JSON-LD name, search title, MCP title, OG heading |
library_description | "A curated pattern library on PATTTTERNS." | meta description, og:description, twitter:description, JSON-LD description, search description, MCP description, OG subtext |
| Canonical URL | /l/{token} | og:url, canonical, JSON-LD @id / url, MCP url, markdown permalink |
Author (if public_show_author) | omit | JSON-LD author, search snippet, OG footer handle |
| Pattern count | 0 | JSON-LD numberOfItems, search / MCP / cards |
| OG image | /og-library.png (per-library branded PNG deferred) | og:image, twitter:image |
Indexable database (required, not only HTML):
-- Illustrative. May be a view over user_profiles + bookmark counts.
create table public.public_library_index (
share_token uuid primary key,
user_id uuid not null,
title text not null,
description text not null,
canonical_url text not null,
author_name text,
show_author boolean not null,
pattern_count int not null default 0,
og_image_url text not null,
share_enabled boolean not null,
updated_at timestamptz not null,
indexed_at timestamptz not null default now()
);
Rules:
- Row exists only while
share_enabled = trueandshare_tokenis set. - Title/description stored after fallbacks so indexes never ship nulls.
- Refresh on title/description/share/bookmark changes (trigger, or the existing client writes plus a reconcile cron).
- RLS:
anon/authenticatedSELECT of this table (or a SECURITY DEFINER function) for public listing; no writes from anon. - Site search, MCP, and
llms.txtmust read this index (or a JSON snapshot built from it) once it exists. Until then,/sitemap-libraries.xmland/l/{token}readuser_profilesvia RLS — do not scrape HTML.
Optional snapshot for static/edge: public/libraries-index.json generated on a schedule (same idea as search-index.json, but runtime data — not Notion build).
JSON-LD and markdown for agents
JSON-LD (on the canonical HTML)
Inject application/ld+json on /l/{token} only (not the owner /library workspace). Shape extends ROADMAP MULTILIBRARY §3b:
{
"@context": "https://schema.org",
"@type": ["Collection", "ItemList"],
"@id": "https://patttterns.com/l/{token}",
"url": "https://patttterns.com/l/{token}",
"name": "<library title>",
"description": "<library description>",
"isPartOf": { "@id": "https://patttterns.com#website" },
"author": { "@type": "Person", "name": "<owner display name>" },
"numberOfItems": 10,
"image": "https://patttterns.com/og-library.png",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "<pattern title>",
"url": "https://patttterns.com/patterns/<slug>"
}
]
}
Omit author when public_show_author is false. Cap itemListElement (e.g. 50) with numberOfItems still equal to the real count.
Markdown
Agents already request Accept: text/markdown (markdown-negotiation on pattern routes). Public libraries handle markdown in public-library on /l/{token} only — not on /library. Prefer a structured library markdown document over stripped HTML:
# {title}
{description}
- URL: https://patttterns.com/l/{token}
- Author: {name} <!-- omit if hidden -->
- Patterns: {n}
## Patterns
1. [{title}](https://patttterns.com/patterns/{slug})
MCP get_public_library returns this markdown in content[] and a JSON blob (same fields). HTML pages keep JSON-LD for crawlers that do not send Accept: text/markdown.
Open Graph image
Replace the generic blue-folder card (public/og-library.png) on per-library URLs. Directory /library may keep the generic card.
Visual contract

| Region | Content |
|---|---|
| Background | Primary theme: blue → violet gradient + dot grid (ogOptions.theme = "primary", dots on) |
| Left | PATTTTERNS isotype (public/patttterns-iso.svg / brand.assets.logo) |
| Heading | Library title |
| Subtext | Library description (truncated to fit) |
| Footer pills | Site version (package.json / brand version) + PATTTTERNS |
| Footer right | @author when shown, otherwise omit |
Colors from repo brand.json: --theme-primary #0267FF, --theme-tertiary #5C23ED, --theme-primary-light #00D1FF.
Generator
Use the programmatic CLI core already depended on as uink-brand-cli (file:../uink-brand-cli):
import { parseBrandFromJson } from "uink-brand-cli/lib/parser.js";
import { generateAssetsInMemory } from "uink-brand-cli/lib/generator.js";
const brandData = parseBrandFromJson(brandJson);
brandData.siteTitle = libraryTitle;
brandData.description = libraryDescription;
brandData.name = "PATTTTERNS";
// Footer-right is currently formatSiteUrlForDisplay(siteUrl).
// Pass author handle here, or extend CLI with ogOptions.footerLabel.
const { files } = await generateAssetsInMemory(brandData, {
force: true,
sourceLogo: patttternsIsoSvg,
ogOptions: { theme: "primary", showDots: true },
});
Do not hand-draw a parallel Satori template. If footer-right cannot show @author without abusing siteUrl, add a small ogOptions.footerLabel in uink-brand-cli and bump the local file dependency.
Serving and cache
Current Phase 1: og:image is static https://patttterns.com/og-library.png (CDN). No Function.
Deferred per-library OG (code in netlify/deferred/library-og.mts):
@resvg/resvg-js is Node-native — not Deno edge. Generate in a Netlify Function (or Node cron), not the MCP edge handler.
| Item | Value |
|---|---|
| Public URL | https://patttterns.com/og/library/{share_token}.png |
| Size | 1200×630 PNG |
| Cache key | hash(title, description, author, show_author, brand version, logo, theme) |
| Store | Netlify Blobs (or equivalent) + long Cache-Control |
| Invalidate | On title/description/author/share save |
| Fallback | /og-library.png if generation fails |
| Crawler | og:image must be absolute, og:image:width 1200, og:image:height 630, type image/png |
Because the app is static export, per-library <meta> cannot come from library/layout.tsx. Serve a single always-200 HTML document at /l/{token} for every user-agent (Google, social crawlers, agents, humans). Do not split by UA or 308 browsers onto /library (cloaking risk + robots.txt Disallow: /library).
Human masonry stays the existing client LibraryFlowView at /library?token= (noindex). The /l/{token} page links to it.
Saving title/description/share does not rebuild Netlify /out (same cost model as not regenerating TSX on every library change). Tokens are unknown at generateStaticParams time.
Cost (BETA, low traffic is fine):
| Path | Runtime | When it runs |
|---|---|---|
/library | Static CDN only (redirector excluded) | Owner / SPA views |
/l/{token} | Node Function + Supabase; Netlify CDN s-maxage=300 | Share + crawler + agent hits |
/og-library.png | Static CDN | Share cards (per-library OG deferred) |
/sitemap-libraries.xml | Node Function; CDN s-maxage=600 | Google/agent sitemap fetches |
Listing: site, search, MCP
Only share_enabled libraries.
Site
- Public directory of library cards: title, description, author, pattern count, OG/cover.
- Reuses ROADMAP SHARED LIBS Features 2–3 (
PublicLibraryCard,OwnerAvatar). - Anonymous
/libraryshould not be a login wall (shared-libs principle). Owner workspace stays available after sign-in.
Site search
Extend SearchResult.type with "library" (or a dedicated libraries fetch merged in the search UI). Fields: title, description, slug = canonical path /l/{token}, searchText = title + description + author. Same normalizeQuery as patterns (Search & MCP Architecture).
Public MCP (POST /mcp)
Read-only discovery. Names avoid colliding with future /mcp/auth get_library:
| Tool | Args | Returns |
|---|---|---|
search_public_libraries | query, limit (default 10, max 50) | Hits from public_library_index |
get_public_library | token or url | Profile + bookmarks + url + markdown/JSON-LD summary |
list_public_libraries | limit, optional sort (updated | count) | Directory page |
Each hit includes: title, description, url (canonical), token, pattern_count, author (or null), og_image. Bookmark rows include pattern title, url, slug, tags — no component TSX.
Update public/mcp/server-card, llms.txt, and add an agent skill browse-public-libraries.md.
Architecture
flowchart TB
owner["Owner edits title / description / share"]
profiles[(user_profiles + bookmarks)]
idx[(public_library_index — Phase 0.1)]
ogFn["Node Function: uink-brand-cli OG"]
blobs[(Blobs PNG cache)]
html["GET /l/token 200 HTML + JSON-LD + og meta"]
md["Accept: text/markdown"]
sm["GET /sitemap-libraries.xml"]
dir["Site directory + search"]
mcp["POST /mcp public library tools"]
owner --> profiles
profiles --> html
profiles --> md
profiles --> sm
profiles --> ogFn --> blobs --> html
profiles --> idx
idx --> dir
idx --> mcp
Privacy: listing a library is opt-in (share_enabled). Token in the path is still a capability URL — do not put private libraries in the index. Regenerating the token drops the old row and OG cache.
Static export: public library HTML/meta is a runtime surface. Do not wait for Notion prebuild. Until public_library_index exists, /l/{token} and /sitemap-libraries.xml read user_profiles via existing RLS (user_profiles_public_select_shared).
Phased delivery
flowchart LR
P0["Phase 0\nIndex + canonical URL"]
P1["Phase 1\nSocial + JSON-LD + OG + md"]
P2["Phase 2\nDirectory + search + MCP"]
P0 --> P1 --> P2
Phase 0 — Canonical URL + index table
Goal: every public library has a stable /l/{token} and a row in public_library_index.
| # | Deliverable |
|---|---|
| 0.1 | SQL for public_library_index (or view) + RLS + backfill from user_profiles |
| 0.2 | Route /l/[token] (profile + bookmarks; 404 if disabled/unknown) — done (Node Function; always 200 SEO HTML for every UA) |
| 0.3 | Do not 308 /library?token= → /l/{token} — done (SPA stays interactive + noindex; share copies /l/) |
| 0.4 | Share clipboard copies canonical URL — done |
| 0.5 | robots.txt: allow /l/; keep owner workspace noindex — done |
| 0.6 | Sitemap entries from live public profiles (/sitemap-libraries.xml) — done; public_library_index still later |
Exit: opening a shared /l/{token} returns 200 with title/description. Disabled shares 404. Legacy ?token= still opens the SPA.
Phase 1 — Social, JSON-LD, markdown (+ static OG)
Goal: crawlers and agents see owner title/description; share cards use a stable OG image.
| # | Deliverable |
|---|---|
| 1.1 | Server-injected title, description, canonical, og:*, twitter:* from live public profile — done |
| 1.2 | Collection/ItemList JSON-LD — done |
| 1.3 | Markdown negotiation (Accept: text/markdown on /l/{token}) — done |
| 1.4 | Per-library OG via uink-brand-cli — deferred (code in netlify/deferred/library-og.mts) |
| 1.5 | og:image absolute URL → static /og-library.png — done |
Exit: Facebook/LinkedIn/Slack debugger (or curl HTML) for /l/{token} shows custom title, description, and 1200×630 PNG — not “My Library”. Coywolf-style checks: og:title, og:description, og:image, og:url present.
Phase 1 implementation notes
Shipped on static export via Netlify (no Next dynamic route):
| File | Role |
|---|---|
src/lib/public-library-shared.mjs | Title/description mapping, URLs, JSON-LD, markdown |
netlify/functions/public-library.mts | Always-200 /l/{token} HTML (title/desc/canonical/og/JSON-LD) + markdown |
netlify/deferred/library-og.mts | Deferred per-library PNG (uink-brand-cli + Blobs) — not deployed |
netlify/functions/sitemap-libraries.mts | GET /sitemap-libraries.xml from user_profiles |
src/components/LibraryShareButton.tsx | Copies /l/{token} |
public/robots.txt | Allow: /l/; Sitemap: /sitemap-libraries.xml; still Disallow: /library |
Every UA gets the same /l/{token} document (index,follow). /library is a static CDN SPA (noindex). No Edge on /library.
Phase 2 — Directory, search, public MCP
Goal: libraries are discoverable without knowing the token.
| # | Deliverable |
|---|---|
| 2.1 | Public directory cards — done (PublicLibraryCard, anonymous /library; /libraries removed → /debug/libraries) |
| 2.2 | Site search includes type: "library" — done (runtime merge from Supabase) |
| 2.3 | MCP tools search_public_libraries, get_public_library, list_public_libraries — done |
| 2.4 | Server card, llms.txt, agent skill — done (browse-public-libraries) |
| 2.5 | Optional libraries-index.json snapshot for edge — open (live RLS list is enough for BETA) |
Exit: a query like “onboarding libraries” returns public libraries in the topbar and via MCP, each with a canonical URL.
Later (not this PRD)
/library/{username}aliases — SHARED LIBS Feature 4.- Multi-library rows — MULTILIBRARY Phase 2; index gains
library_id+ slug. - Gemini auto-fill description — MULTILIBRARY Phase 3c.
- View counters — SHARED LIBS Feature 1.
Acceptance criteria
- Public library HTML
title/og:titleequal ownerlibrary_title(or fallback), never the generic Explorer title. og:descriptionequals ownerlibrary_description(or fallback).og:urland<link rel="canonical">arehttps://patttterns.com/l/{token}.og:imageis absolute 1200×630 PNG (/og-library.pnguntil per-library branded OG returns).- JSON-LD validates as Collection/ItemList with name, description, url, and pattern items.
Accept: text/markdown(or MCP text) returns title, description, canonical URL, and pattern links.public_library_index(or equivalent) stores title, description, canonical URL, counts, OG URL for everyshare_enabledlibrary./library?token=remains the interactive SPA (noindex); share copies/l/{token}.robots.txtallows/l/; owner/libraryremainsnoindex.- Sitemap lists canonical public library URLs (
/sitemap-libraries.xml). - Site directory and site search list public libraries.
- Public MCP can search, list, and fetch a library without auth and without TSX bodies.
- Private (
share_enabled = false) libraries never appear in OG routes (404) or canonical HTML. - Regenerating the share token removes the old URL from index/sitemap and issues a new canonical.
Risks
| Risk | Mitigation |
|---|---|
Static export has one HTML for /library | Always-200 runtime /l/{token} Edge HTML; SPA stays noindex |
UA-split 308 to /library | Rejected — Google would index a Disallow URL / cloaking |
| Token-in-path still guessable if listed | Listing is the point of public share; private stays off-index |
| Token rotation breaks citations | Same as today’s share links; document in UI |
uink-brand-cli footer is siteUrl | Pass handle as footer label or extend CLI |
Native @resvg on Edge | Node Function only |
MCP tool name clash with /mcp/auth | *_public_library* names |
| Index lag vs live bookmarks | Reconcile on write + periodic cron |
| Username SEO delayed | Canonical /l/{token} is enough for this PRD |
Success metrics
| Metric | Target |
|---|---|
Public library shares with custom og:title in crawler HTML | 100% of share_enabled |
MCP get_public_library includes canonical url | 100% |
| Index/sitemap/MCP omit private libraries | Zero leaks |
| OG generation success (cached or fresh) | > 99% |
Related documents
- ROADMAP Shared Libraries — directory, cards, username URLs, OG (this PRD is the execution spec for OG + indexing + MCP/JSON-LD)
- ROADMAP Multi-Library — title/description fields, JSON-LD sketch §3b, token URLs, SEO slugs deferred
- Authenticated MCP Library PRD — owner workspace MCP, not public discovery
- PATTTTERNS MCP Server PRD — public
/mcppatterns + components - Search & MCP Architecture
- Sitemap setup
- DB setup
Decision log
| Date | Decision | Rationale |
|---|---|---|
| 2026-08 | Canonical /l/{share_token} now; username slugs later | Unlocks OG/JSON-LD/MCP without waiting on handles or multi-library |
| 2026-08 | Keep ?token= as the noindex SPA (not a 308) | Existing interactive masonry; Google must not follow a redirect onto Disallow: /library |
| 2026-08 | /l/{token} as Node Function (not Edge) | Netlify CLI on macOS spawn EBADF with too many Edge Deno config loads |
| 2026-08 | No Edge on /library | Owner workspace is static CDN; Functions only on share/crawler paths |
| 2026-08 | Title/description are the only copy fields | Already the AI-context UX on /library |
| 2026-08 | OG via uink-brand-cli deferred; use /og-library.png | Thin Phase 1; drop Function + local file: dep from deploy path |
| 2026-08 | markdown-negotiation paths in-file only (not 7 toml rows) | Fewer local CLI Deno loads; same production paths |
| 2026-08 | Phase 2 directory on anonymous /library; dedicated /libraries later removed (debug only) | Marketplace UI without replacing owner SPA; cards link Flow |
| 2026-08 | Humans never land on /l/ UI; marketplace/search → Flow | /l/ is crawler/agent/markdown document; JS location.replace to /library?token= |
| 2026-08 | Public MCP tools named *_public_library* | Avoid clash with authenticated get_library |
| 2026-08 | public_library_index required for search/MCP | Social HTML is not an index; sitemap currently reads user_profiles RLS until the table exists |