OpenLinks is driven by three JSON files:
data/profile.jsondata/links.jsondata/site.json
The app renders from these files after validation. This document explains what each file controls, what is required, and how to extend safely.
For a complete day-2 audit checklist of every data-driven customization area, use docs/customization-catalog.md.
Treat this document as the canonical contract/reference layer, not the default first editing surface.
Recommended order:
- Prefer the repo's AI workflows/skills and automation docs for routine CRUD.
- Prefer the Studio webapp when the browser-based self-serve editor covers your workflow. For referral editing today, Studio still relies on Advanced JSON rather than first-class referral controls.
- Edit
data/*.jsondirectly only when you want lower-level control or need the manual fallback path.
Supporting docs:
docs/openclaw-update-crud.mddocs/ai-guided-customization.mddocs/studio-self-serve.md
The three-file contract in this document and the upstream schemas under
schema/ are also mirrored by
open-links-sites, so breaking
changes here can propagate into that downstream project as well.
- Keep core fields in schema-defined keys.
- Put extensions under
customto avoid collisions. - Validate before build.
- Prefer explicit ordering when you care about exact link sequence.
- Use rich metadata only where it adds value.
| File | Purpose | Required for build | Notes |
|---|---|---|---|
data/profile.json |
Primary entity identity and bio metadata | Yes | Stable compatibility path for the primary hero/entity record |
data/links.json |
All rendered links + groups + order | Yes | Supports simple, rich, and payment cards |
data/site.json |
Theme, UI preferences, quality policy | Yes | Also controls quality, sharing, and deploy-relevant behavior |
Schema: schema/profile.schema.json
data/profile.json remains the stable upstream contract path for backward
compatibility, but the record itself can represent the primary public entity for
the site, including a person or an organization.
name(string)headline(string)avatar(URI string)bio(string, max 500)
entityType(optional string enum:personororganization)- When omitted, runtime behavior defaults to
personfor backward compatibility. - Keep using
data/profile.jsoneven for organizations; the path is stable across validation, Studio, and downstream consumers.
profile.avatarremains the source-of-truth URL indata/profile.json.- During explicit
bun run content:refreshflows, avatar sync fetches and stores a local copy atpublic/cache/profile-avatar/profile-avatar.<ext>. - Avatar sync writes the committed stable manifest
data/cache/profile-avatar.jsonplus the gitignored runtime overlaydata/cache/profile-avatar.runtime.json. - Runtime rendering uses the local resolved path from the committed avatar manifest, not the raw remote URL.
- The main profile/site QR also reuses the resolved avatar and automatically pairs it with the site brand mark when both assets are available; there is no separate profile-QR config surface.
- On fetch failure:
- cached local avatar is reused when available, or
- fallback file
public/profile-avatar-fallback.svgis used.
- Force refresh is available via
bun run avatar:sync -- --forceorOPENLINKS_AVATAR_FORCE=1. - All cache-backed remote fetches are governed by
data/policy/remote-cache-policy.jsonplus optional fork-owned additions indata/policy/remote-cache-policy.local.json; adding a new remote host without shared or local policy coverage is a validation error.
entityTypelocationpronouns(primarily relevant forentityType: "person")statusprofileLinks(array of{ label, url })contact(object, supportsemail,website, plus extensions)custom(extension namespace)
profileLinks remains an optional profile-level field for lightweight identity links, but it is not the source of truth for the shipped Quick Links header strip. Quick Links derive from eligible top-level entries in data/links.json.
If you are using the recommended AI or Studio paths, treat the JSON examples below as reference shapes the tools should produce, not as proof that hand-editing is the preferred workflow.
{
"name": "Your Name",
"headline": "What you do",
"avatar": "https://example.com/avatar.jpg",
"bio": "One to two sentences about your work and interests.",
"entityType": "person",
"location": "City, Country",
"profileLinks": [
{
"label": "GitHub",
"url": "https://github.com/your-handle"
}
],
"contact": {
"email": "hello@example.com"
},
"custom": {
"profileVariant": "default"
}
}{
"name": "Bright Builds LLC",
"headline": "Product engineering for ambitious teams",
"avatar": "https://example.com/logo.png",
"bio": "We design and ship software systems, automation, and product platforms.",
"entityType": "organization",
"contact": {
"website": "https://example.com",
"email": "hello@example.com"
}
}Schema: schema/links.schema.json
links.json is where most customization happens.
links(required array)groups(optional array)order(optional array of link IDs)custom(optional extension object)
Every item in links must include:
idlabeltype(simple,rich, orpayment)
url is required for simple and rich links. payment links may omit url if payment.rails is configured.
icondescriptiongrouporderenabledreferral(referral/affiliate/promo disclosure data)metadata(rich card metadata)enrichment(build-time enrichment policy)payment(tips/payment rails + QR settings)custom
Referral support is additive to ordinary URL-based links. Keep type as simple or rich; do not introduce a separate referral link type in your data.
Recommended maintainer path for referral authoring:
- Prefer the repo-native AI CRUD docs (
docs/openclaw-update-crud.md,docs/ai-guided-customization.md). - Use
skills/referral-management/SKILL.mdwhen the work involves reusable families, offer variants, matcher/link shapes, or a shared-vs-fork scope decision. - Use Studio only when the browser-based path fits and Advanced JSON is acceptable for the change.
- Edit
data/links.jsondirectly only when you intentionally want the lower-level fallback path.
Treat referral authoring as a layered model:
- Shared higher-level catalog:
data/policy/referral-catalog.json - Optional fork-owned overlay:
data/policy/referral-catalog.local.json - Runtime/render contract:
links[].referral
The catalog files are for reusable family, offer, and matcher knowledge. The
saved link still resolves to links[].referral at runtime, and manual link
fields remain authoritative when they intentionally differ from catalog-backed
defaults.
Use the shared catalog when the family or matcher is generic enough to help
other forks. Use data/policy/referral-catalog.local.json for fork-specific
offers, overrides, or experiments that should stay out of upstream PRs.
Canonical stable fields:
| Field | Meaning | Notes |
|---|---|---|
kind |
Disclosure umbrella value | Allowed values: referral, affiliate, promo, invite |
catalogRef |
Optional pointer to shared catalog data | Nested ids may include familyId, offerId, and/or matcherId |
visitorBenefit |
What the visitor gets | Optional; either side may be absent |
ownerBenefit |
What the site owner/project gets | Optional; broad enough for cash, credit, support, or indirect benefit |
offerSummary |
Short public-facing offer summary | Card-friendly summary of the deal |
termsSummary |
Short conditions/caveats summary | Keep this factual and condition-oriented |
termsUrl |
Canonical public terms/landing URL | Optional quiet verification/source link |
code |
Referral/promo/invite code | Optional structured code field |
custom |
Extension namespace | Use for link-local extras that should not become shared schema fields |
Notes:
referral: {}is valid as a soft marker, butbun run validate:datawill warn until you add one or more meaningful disclosure fields.kindalone classifies the link but does not count as sufficient disclosure by itself.catalogRefis the higher-level authoring pointer. It does not replace the runtime referral object; it seeds it.- One-sided disclosures are valid. You do not need to invent both sides of the offer.
- For supported profile-family URLs that are acting as referral/promo destinations, use
links[].enrichment.profileSemantics: "non_profile"unless you intentionally want profile-style rendering.
Use this when you want to mark a link as promotional first and fill in fuller disclosures after review:
{
"id": "support-offer",
"label": "Supporter Offer",
"url": "https://example.com/support",
"type": "simple",
"icon": "globe",
"description": "Support offer landing page",
"referral": {}
}This is valid, but it intentionally triggers a warning until you add real disclosure content.
{
"id": "coffee-club",
"label": "Coffee Club",
"url": "https://example.com/coffee?ref=openlinks",
"type": "simple",
"icon": "globe",
"description": "Subscription coffee delivery",
"referral": {
"kind": "promo",
"visitorBenefit": "Get 20% off your first order",
"ownerBenefit": "Supports the project",
"offerSummary": "Save on your first bag of coffee.",
"termsSummary": "New customers only. Cannot be combined with other offers.",
"termsUrl": "https://example.com/referral-terms",
"code": "OPENLINKS"
}
}{
"id": "cluborange-referral",
"label": "Join Club Orange",
"url": "https://app.cluborange.org/pryszkie",
"type": "rich",
"icon": "cluborange",
"description": "Bitcoin social club",
"enrichment": {
"enabled": true,
"profileSemantics": "non_profile"
},
"referral": {
"kind": "referral",
"ownerBenefit": "Supports the project",
"offerSummary": "Use this signup link to support OpenLinks while joining Club Orange."
}
}Use this when a saved link should inherit shared family/offer defaults while keeping one or two manual link-level disclosures authoritative:
{
"id": "cluborange-referral",
"label": "Join Club Orange",
"url": "https://signup.cluborange.org/co/pryszkie",
"type": "rich",
"icon": "cluborange",
"description": "Bitcoin social club",
"enrichment": {
"enabled": true,
"profileSemantics": "non_profile"
},
"referral": {
"catalogRef": {
"offerId": "club-orange-signup"
},
"ownerBenefit": "Supports the project",
"termsUrl": "https://www.cluborange.org/signup"
}
}Short rule:
- Manual
links[].referralfields win. - Catalog data seeds the runtime referral object.
- Generated referral data only fills remaining blanks.
Deeper behavior:
- Manual, catalog, and generated values merge field-by-field rather than replacing the whole referral object.
- Catalog data may come from an explicit
catalogRefor a deterministic matcher hit when the link does not point at a catalog entry directly. - Generated data is allowed to fill empty
visitorBenefit,ownerBenefit,offerSummary,termsSummary,termsUrl, orcodefields when public enrichment found something useful and neither manual data nor the catalog already supplied them. - Generated data is assistive, not authoritative. Maintain the saved manual disclosure whenever a generated phrase is incomplete, stale, jurisdiction-specific, or simply worse than the human-authored text.
- If manual and generated referral fields disagree, validation surfaces a drift warning instead of silently picking the generated text.
- If you need a fork-only catalog override, prefer
data/policy/referral-catalog.local.jsonover turning the shared upstream catalog into a personalized file.
Generated referral output and reports can include a few extra fields beyond the authoring contract:
completeness:full,partial, ornoneprovenance: which referral fields came frommanual,catalog, orgeneratedsourcescatalog: resolved catalog contribution details such as family, offer, matcher, and whether the match came from an explicit ref or matcher lookuporiginalUrlresolvedUrlstrategyIdtermsSourceUrl
You usually do not need to author these by hand. They exist to make enrichment/debug output transparent and auditable.
Treat public extraction as assistive:
- headline-like public promo copy may populate
offerSummary termsSummaryis intentionally stricter and may stay blank- public extraction should help you author faster, not replace your judgment
For script-backed verification and warning interpretation after changing a referral link, use docs/social-card-verification.md.
Downstream note:
links[].referralremains the additive runtime/render contract mirrored byopen-links-sites, so downstream maintainers should review the referral contract notes when bumping upstream pins.data/policy/referral-catalog.jsonis also downstream-visible because it lives on the shared policy path; changes there deserve the same compatibility review as other shared policy data.data/policy/referral-catalog.local.jsonis the fork-owned overlay side of the model and should not be treated as shared upstream contract data.- Later referral card UI-only changes are lower-risk for downstream than contract/schema, policy, or script-entrypoint changes.
The profile header Quick Links strip is derived behavior from existing eligible links[] entries. It is not backed by profileLinks and does not use a separate manual registry.
Current shipped behavior:
- derives from enabled top-level social/profile links in
data/links.json - only considers supported social/profile-style destinations
- keeps one winner per platform
- uses a locked platform-priority order first, then existing content order
- renders as an icon-first strip above the profile action bar when eligible links exist
- disappears entirely when no eligible Quick Links exist
The current tie-break path for duplicate same-platform links is links[].custom.quickLinks.canonical=true, which only resolves conflicts inside the same platform and does not force inclusion outside the default eligibility rules.
Quick Links are intentionally constrained by default in the current release:
- there is no dedicated Quick Links registry
- there is no separate global Quick Links config surface yet
- deeper ordering, visibility, or color-mode controls are future work
This is a renderer-level behavior only. It does not change the upstream open-links-sites data/schema contract because it consumes the existing links[] shape rather than introducing a new shared data surface. For broader downstream compatibility notes, see docs/downstream-open-links-sites.md.
Link icons resolve using this precedence:
links[].iconalias match fromsrc/lib/icons/known-sites-data.ts- URL domain match (exact or subdomain) from the same static registry
- Optional single-step remap through
site.ui.brandIcons.iconOverrides - Generic fallback glyph
If links[].icon is unknown, validation emits a warning and runtime still attempts domain-based resolution.
Known-site logo rendering is contrast-aware by default: brand color is preferred, and fallback palette adjustments are applied when needed to keep icons visible.
{
"id": "github",
"label": "GitHub",
"url": "https://github.com/your-handle",
"type": "simple",
"icon": "github",
"description": "Code, experiments, and OSS",
"enabled": true,
"custom": {}
}{
"id": "project-home",
"label": "Project",
"url": "https://example.com/project",
"type": "rich",
"icon": "github",
"description": "Project landing page",
"enabled": true,
"metadata": {
"title": "Project title",
"description": "One-line project summary",
"image": "https://example.com/preview.png",
"handle": "project-owner",
"sourceLabel": "example.com"
},
"enrichment": {
"enabled": true
},
"custom": {}
}For supported profile links, start with the link shell and let build-time enrichment plus the committed public/authenticated caches fill in the richer metadata:
{
"id": "github",
"label": "GitHub",
"url": "https://github.com/pRizz",
"type": "rich",
"icon": "github",
"description": "Code, experiments, and open-source projects",
"enabled": true,
"enrichment": {
"enabled": true
}
}When enrichment succeeds, runtime can reuse the normalized metadata for avatar-first profile rendering, handle display, audience metrics, follower-history snapshots, and card analytics availability without requiring manual metadata in data/links.json.
The same identity metadata now also feeds QR center badges for non-payment links: when runtime resolves both a primary identity image (metadata.profileImage first, then a distinct metadata.image) and a known site identity from links[].icon or the URL, the QR code automatically renders a two-item side-by-side badge with both identities. No extra JSON configuration is required.
If you are using AI workflows or Studio for CRUD, this is the reference shape to target. Directly editing data/links.json is the manual fallback path.
OpenLinks supports grouped or flat list presentation.
- Set
groupon each link. - Define matching group objects in
groups. - Group labels are rendered depending on
site.ui.groupingStyle.
Group example:
"groups": [
{ "id": "social", "label": "Social", "order": 1 },
{ "id": "work", "label": "Work", "order": 2 }
]Rendering order resolves using this precedence:
- Explicit
orderarray at root (links.order). - Per-link numeric
ordervalues. - Input order fallback.
This supports:
- fully curated order,
- partially curated order,
- or default natural order.
Rich links can include manual metadata and/or generated metadata.
Payment support is available in two ways:
- Dedicated payment cards with
type: "payment". - Payment metadata on regular
simple/richlinks viapayment(the link upgrades to payment-card rendering at runtime).
payment supports:
qrDisplay:always,toggle,hiddenprimaryRailIdeffects.enabled: opt the card into decorative special effectseffects.effects: optional explicit effect list (particles,lightning-particles,glitter-particles)effects.glitterPalette:gold,iceeffects.bombasticity: normalized effect intensity from0to1;0disables the decorative layer, the live curve reaches its busiest/fastest presentation by0.1, and0.1..1intentionally plateau at that maximumrails: array of rail objects
Each payment rail keeps two identity concepts separate when needed:
rail: the payment network, protocol, or canonical payment rail (lightning,bitcoin,cashapp, etc.)provider: optional branded provider/service/wallet identity for that rail, using a known-site id or alias fromsrc/lib/icons/known-sites-data.ts
Use provider when the payment brand differs from the rail itself, for example Strike on Lightning.
If provider is omitted, runtime falls back to existing identity inference from payment.rails[].icon, payment.rails[].url, links[].icon, and links[].url where possible.
Supported rail values:
patreonkofipaypalcashappstripecoinbasebitcoinlightningethereumsolanacustom-crypto
Per-rail QR settings (payment.rails[].qr) support:
enabledfullscreen:enabled,disabledstyle:square,rounded,dotsforegroundColor,backgroundColor: optional explicit overrides for the QR modules/backgroundlogoMode:rail-default,custom,nonelogoUrl(required whenlogoModeiscustom)logoSizebadge.mode:auto,custom,nonebadge.size: optional center-badge size override using the same normalized scale aslogoSizebadge.items: up to 2 entries using{ "type": "rail" },{ "type": "site", "value": "<known-site-id>" }, or{ "type": "asset", "value": "/payment-logos/example.svg" }payload(optional explicit QR payload override)
Payment rails can include explicit app links via payment.rails[].appLinks for wallet/app-specific deep links.
When payment.rails[].qr.badge.mode is auto, runtime resolves provider identity in this order:
- explicit
payment.rails[].provider - known
payment.rails[].icon - known
payment.rails[].url - known
links[].icon - known
links[].url
Auto mode composes the provider brand and rail symbol when both resolve and differ. If only the rail identity resolves, or the provider and rail are the same brand, runtime falls back to the normal single-logo behavior for the active logoMode.
badge.mode: custom always wins over auto/provider inference.
For branded payment cards, that same explicit or inferred provider identity also feeds card chrome and other shared payment identity surfaces. If a new provider should render outside the QR too, register it in the shared known-site/icon registry instead of relying on badge.items.asset.
badge.items.asset affects only the QR center badge. It does not change card-shell icon resolution, known-site matching, or other shared identity surfaces.
Without explicit payment.rails[].qr.badge.mode or payment.rails[].qr.logoMode overrides, runtime applies the same shared identity logic by default: compose provider/company + rail when both resolve distinctly, otherwise fall back to the normal single-logo behavior for the active/default logo mode.
When QR colors are omitted, runtime defaults follow the active theme using --text-primary for QR modules and --surface-panel for the background.
When payment.effects.enabled is true and no explicit effect list is provided, runtime defaults to subtle ambient particles for standard payment cards, and to both lightning-particles and gold glitter-particles for cards whose primary rail is Lightning. When payment.effects.bombasticity is omitted, runtime falls back to site.ui.payments.effects.bombasticityDefault, then to the built-in midpoint default of 0.5, which now renders at the maximum live treatment.
{
"id": "support",
"label": "Support My Work",
"type": "payment",
"description": "Tips help me ship more features",
"payment": {
"qrDisplay": "always",
"primaryRailId": "btc",
"rails": [
{
"id": "btc",
"rail": "bitcoin",
"address": "bc1qexampleaddress",
"amount": "0.0005",
"message": "Thanks for the support",
"qr": {
"style": "dots",
"logoMode": "rail-default",
"fullscreen": "enabled"
}
},
{
"id": "patreon",
"rail": "patreon",
"url": "https://patreon.com/example"
}
]
},
"custom": {}
}Use provider when the payment website/company is distinct from the rail itself:
{
"id": "strike-lightning",
"label": "Strike Tips",
"type": "payment",
"description": "Tip me on Lightning via Strike",
"payment": {
"primaryRailId": "lightning",
"rails": [
{
"id": "lightning",
"rail": "lightning",
"provider": "strike",
"label": "Strike Lightning",
"address": "openlinks@strike.me",
"qr": {
"badge": {
"mode": "auto"
}
}
}
]
}
}With the example above:
- card chrome can use Strike identity
- QR auto-badge mode can compose Strike + Lightning automatically
- downstream repos only need the upstream sync/pin update unless they want to opt into explicit
provideron their own data
To add a future branded provider such as River, Voltage, or Wallet of Satoshi through the same path:
- Register the provider once in
src/lib/icons/known-sites-data.ts. - Add its graphic in
src/lib/icons/site-icon-graphics.tsandsrc/lib/icons/known-site-icons.tsx. - Add an optional asset in
public/payment-logos/if you want an explicit custom-logo file path. - Use
payment.rails[].provideror rely on known icon/URL inference.
No QR resolver branch should be needed for a new provider once the central registration work is done.
- Remote rich-image URLs are source data, but runtime does not render raw remote URLs.
- During explicit
bun run content:refreshflows,images:syncfetches remote rich-link and SEO image candidates and writes:- committed baked files in
public/cache/content-images/<content-hash>.<ext> - committed stable manifest
data/cache/content-images.json - gitignored runtime overlay
data/cache/content-images.runtime.json
- committed baked files in
- Runtime rich-card
metadata.imagevalues resolve to baked local paths when available. - Runtime also localizes
metadata.ogImage,metadata.twitterImage, andmetadata.profileImagewhen baked local assets are available. - If a link would render as a rich card without a materialized preview image,
bun run validate:data(and thereforebun run build) fails with remediation guidance.bun run devremains Vite-only, so run validation or refresh before local preview after content edits. - Header-only revalidation data is kept in the runtime overlay so routine
images:syncruns do not rewrite tracked cache files unless the cached asset payload actually changes. - Force refresh is available via
bun run images:sync -- --forceorOPENLINKS_IMAGES_FORCE=1.
Supported keys include:
titledescriptionprofileDescription: profile-authored bio/summary for supported social profile links; when present, this wins over generic description-source rulesdescriptionSource:fetched(prefer fetched metadata description),manual(prefer top-levellinks[].description)image: canonical render image used by cards todayogImage: raw Open Graph image candidate when presenttwitterImage: raw Twitter card image candidate when presentprofileImage: canonical identity/avatar imagehandle(canonical username/handle without leading@; runtime renders as@handle)followersCountfollowersCountRawfollowingCountfollowingCountRawsubscribersCountsubscribersCountRawimageFitmobileImageLayoutsourceLabel- when this is a host-like custom domain for a known platform, footer rendering clarifies it as
Platform · domain(for exampleSubstack · peter.ryszkiewicz.us)
- when this is a host-like custom domain for a known platform, footer rendering clarifies it as
sourceLabelVisibleenrichmentStatusenrichmentReasonenrichedAtcustom
Image-role rules:
imageremains the backward-compatible preview/render image for card rendering.ogImageandtwitterImagepreserve source provenance separately instead of being folded intoimage.- Generic metadata parsing defaults
imagetoogImage ?? twitterImage. - Platform-specific augmentation may keep low-value placeholders in
ogImage/twitterImagefor completeness while choosing a differentimagefor rendering. profileImageis independent from all preview/social image roles and may equalimage.- For supported social-profile links, runtime now backfills
image -> profileImageby default whenprofileImageis missing, unless the platform is explicitly excluded because itsimagecommonly represents preview/banner media instead of an avatar. - Exclusion reasons live in shared code, currently
src/lib/content/social-profile-fields.ts, rather than a user-edited JSON policy file.
Minimal manual override example:
{
"id": "x",
"label": "X",
"url": "https://x.com/pryszkie",
"type": "rich",
"icon": "x",
"description": "Short updates and project notes",
"metadata": {
"handle": "pryszkie",
"profileDescription": "We the people demand justice for the victims.",
"sourceLabel": "x.com"
},
"enrichment": {
"enabled": true
}
}Runtime notes:
profileDescriptiononly changes card copy for supported social profile links. Non-profile links continue to usedescriptionSourceplus the existing fetched/manual fallback order.followersCount*,followingCount*, andsubscribersCount*drive the profile-header metric chips on supported social cards.- Those audience fields also feed the follower-history pipeline when a nightly or local
bun run followers:history:syncsnapshot is taken. links[].enrichment.profileSemantics="non_profile"is the supported way to keep a rich link on generic rich-card behavior even when the URL belongs to a profile-capable site family.
- Runtime and enrichment use URL-only handle extraction (no HTML/meta-tag scraping).
- Resolution precedence is:
- manual
links[].metadata.handle - URL-derived handle when supported
- manual
links[].enrichment.profileSemanticscontrols whether a rich link participates in profile handling:auto(default): infer from URL family plus the handle rules aboveprofile: require profile semantics when a supported profile can be resolved; validation warns when it cannotnon_profile: opt out of profile semantics, handle warnings, profile-header metadata expectations, and avatar-first profile layout
- Supported extractor families in v1: GitHub, X/Twitter, LinkedIn, Facebook, Instagram, Medium, Substack patterns.
- If a URL is from a supported family but no handle can be resolved and
metadata.handleis missing, validation emits a warning-level handle coverage issue unlesslinks[].enrichment.profileSemantics="non_profile"is set. - Handle coverage warnings are non-strict-blocking and do not fail
bun run validate:data:strict.
Current profile-card-capable rich-link families include:
- GitHub: avatar + follower/following counts when public profile HTML exposes them
- Instagram: avatar + follower/following counts
- LinkedIn: authenticated-cache-backed avatar-first profile cards, including
profileDescriptionwhen the cached metadata provides it - Medium: avatar-first profile cards with public follower-count augmentation
- Primal: avatar-first profile cards with public follower/following metrics when available
- Substack: avatar-first profile cards with subscriber counts and custom-domain support via explicit/manual handle fallback
- X: avatar-first profile cards with best-effort public follower/following metrics and optional
profileDescription - YouTube: avatar + subscriber counts
- Facebook: authenticated-cache-backed avatar-first profile cards without audience-count guarantees in the current pass
Profile styling is still data-driven rather than a separate link type. A supported social profile URL plus the normalized metadata above is what flips a card onto the avatar-first profile path.
Per-link controls:
enabledprofileSemantics:auto(default),profile, ornon_profileallowKnownBlocker: explicit override to force-attempt enrichment for a known blocked domainauthenticatedExtractor: use committed authenticated cache instead of public enrichment for true auth-required domains (currently LinkedIn and Facebook)authenticatedCacheKey: optional cache-key override (default useslink.id)facebookPageMetrics: opt into Meta Graph Pagefollowers_countcapture for Facebook Page links; setenabled: true,pageId, and optionallyapiVersion(defaultv24.0).pageIdis the Meta Graph Page ID, which may differ from the numeric ID in a public Facebook URL. Runtime sync readsOPENLINKS_FACEBOOK_PAGE_ACCESS_TOKENand does not usefan_countor crawler-visible likes as follower history. If Graph returns code100/ subcode33, verify the Business Suite Page ID, the token's Page access, and the Page object ID that succeeds in Graph API Explorer.sourceLabelsourceLabelVisiblecustom
Site-level default enrichment behavior is defined in site.ui.richCards.enrichment.
site.ui.richCards.enrichment supports:
enabledByDefault: whether rich links attempt enrichment when link-levelenrichment.enabledis omitted.timeoutMs: per-attempt request timeout.retries: retry count after the first attempt.metadataPath: generated metadata output path.reportPath: generated enrichment report path.publicCachePath: committed stable public metadata cache manifest path (defaultdata/cache/rich-public-cache.json), refreshed only by explicit write-cache flows.authenticatedCachePath: authenticated cache manifest path (defaultdata/cache/rich-authenticated-cache.json).authenticatedCacheWarnAgeDays: stale-cache warning threshold in days (default30, warning-only).failureMode:immediate(default) oraggregate.immediate: stop strict enrichment on first blocking failure.aggregate: process all eligible links, then fail after reporting all blockers.
failOn: blocking reasons for strict enrichment. Default:["fetch_failed", "metadata_missing"].allowManualMetadataFallback: defaulttrue. Whenmetadata_missingoccurs, any manuallinks[].metadata.title|description|imagedowngrades to warning.
Canonical known-blocker policy registry:
data/policy/rich-enrichment-blockers.json- Schema:
schema/rich-enrichment-blockers.schema.json
Canonical public enrichment cache registry:
data/cache/rich-public-cache.jsonschema/rich-public-cache.schema.json- local runtime overlay:
data/cache/rich-public-cache.runtime.json(gitignored) - local runtime overlay schema:
schema/rich-public-cache.runtime.schema.json
Canonical authenticated extractor + cache registries:
data/policy/rich-authenticated-extractors.jsonschema/rich-authenticated-extractors.schema.jsondata/cache/rich-authenticated-cache.jsonschema/rich-authenticated-cache.schema.jsonpublic/cache/rich-authenticated/(committed local assets)output/playwright/auth-rich-sync/(diagnostics, gitignored)
Canonical committed image cache registry:
data/cache/content-images.jsonpublic/cache/content-images/(committed local assets)data/cache/content-images.runtime.json(gitignored runtime revalidation state)
When an enrichment-enabled rich link URL matches a status=blocked registry entry, enrichment fails early with reason known_blocker unless links[].enrichment.allowKnownBlocker=true is set for that link.
During routine bun run enrich:rich / bun run enrich:rich:strict runs, successful direct/public enrichment uses fetched metadata for the current generated output but only persists volatile revalidation state (etag, lastModified, cacheControl, expiresAt, checkedAt) into the local runtime overlay. The committed stable manifest at data/cache/rich-public-cache.json is refreshed only by explicit write flows such as bun run enrich:rich:strict:write-cache or other dedicated cache-sync commands like bun run public:rich:sync. If fetched metadata drifts while stable writes are disabled, OpenLinks clears runtime freshness for that cache key so stale committed metadata is never treated as fresh on later runs.
If a direct/public fetch fails but a committed public cache entry already exists, enrichment reuses that stale cached metadata as a warning-level fallback. In bun run validate:data:strict, that stale-cache reuse remains non-strict-blocking when the cached metadata is complete and does not require manual fallback; stale cache entries that are incomplete still fail strict validation. No raw public HTML snapshots are committed, and header-only refreshes no longer rewrite tracked cache timestamps.
Built-in public augmentation currently covers Medium (RSS/feed), Rumble (public about-page fetch with avatar/banner separation), Substack (canonical public profile fetch with original source-label preservation), X profiles (oEmbed + avatar), X communities (public OG community metadata), Instagram (public page metadata), and YouTube (public page metadata) without using authenticatedExtractor.
When links[].enrichment.authenticatedExtractor is configured, enrichment uses committed cache entries (reason=authenticated_cache) and fails early with authenticated_cache_missing if cache data/assets are missing or invalid.
bun run content:refresh runs strict enrichment with runtime-only public-cache behavior and fails on configured blocking reasons plus known-blocker policy violations. bun run content:refresh:write-cache intentionally persists stable public-cache updates. bun run dev, bun run build, and bun run build:strict consume committed content without running enrichment or modifying tracked outputs.
Temporary emergency local bypass is available with OPENLINKS_RICH_ENRICHMENT_BYPASS=1.
OpenLinks can publish append-only follower/subscriber snapshots for the profile-oriented platforms that currently expose audience counts.
Canonical public outputs:
public/history/followers/index.jsonpublic/history/followers/<link-id>.csv
Update command:
bun run followers:history:syncHistory rules:
- one row is appended per nightly/local snapshot run even if the count is unchanged
- observed drops are preserved exactly as captured
- rows stay append-only unless a maintainer manually edits the CSV later
- each row keeps audit columns:
observedAt,linkId,platform,handle,canonicalUrl,audienceKind,audienceCount,audienceCountRaw,source
Current index.json shape:
{
"version": 1,
"updatedAt": "2026-03-10T02:29:19.676Z",
"entries": [
{
"linkId": "github",
"label": "GitHub",
"platform": "github",
"handle": "prizz",
"canonicalUrl": "https://github.com/pRizz",
"audienceKind": "followers",
"csvPath": "history/followers/github.csv",
"latestAudienceCount": 90,
"latestAudienceCountRaw": "90 followers",
"latestObservedAt": "2026-03-10T02:29:19.676Z"
}
]
}Representative CSV:
observedAt,linkId,platform,handle,canonicalUrl,audienceKind,audienceCount,audienceCountRaw,source
2026-03-10T02:29:19.676Z,github,github,prizz,https://github.com/pRizz,followers,90,90 followers,public-cacheThe follower-history sync reads the normalized audience metadata that runtime also uses for profile-card header metrics. That means public-augmented profiles, authenticated-cache-backed profiles, and opt-in Facebook Page Graph metrics can contribute history when they expose a primary audience field.
History files are link-scoped rather than platform-scoped. This avoids blending separate entities that share a platform, such as two x profiles or an x profile plus an x community. When adding or changing Instagram, Medium, X, Primal, YouTube, or Facebook Page links that should publish analytics, run bun run public:rich:sync -- --only-link <link-id> for each link before bun run followers:history:sync; Instagram metadata can lag the browser-rendered public counts, X oEmbed enrichment alone does not expose follower counts, and Facebook Page history uses Meta Graph followers_count rather than crawler-visible likes.
The social-card system now exposes a few behavior-only surfaces that are driven by the data above rather than separate config keys:
- Profile header:
- analytics button appears when follower-history index data is available
- share button is always present and uses a clean-URL native-share/copy payload
- vCard download button appears only when
site.sharing.vcard.enabledistrue
- Card header row:
- analytics button appears only for cards that have public follower-history data
- share button appears on non-payment cards, including cards without history
- Analytics UI:
- default range is
30D - available presets are
30D,90D,180D, andAll - charts stay mostly separate by platform unless a future charting change can preserve legible multi-axis comparison
- default range is
Use docs/social-card-verification.md for the current manual QA checklist and automated coverage map for these surfaces.
First-run authenticated setup command:
bun run setup:rich-auth(captures only missing/invalid authenticated cache entries)- Optional one-link capture:
bun run auth:rich:sync -- --only-link <link-id> - Force one-link refresh even when cache is valid:
bun run auth:rich:sync -- --only-link <link-id> --force - Clear cache entries/assets before recapture:
bun run auth:rich:clear -- --only-link <link-id>(or--all)
Extractor scaffolding command:
bun run auth:extractor:new -- --id <extractor-id> --domains <csv> --summary \"<summary>\"
Local auth wait tuning for one-off LinkedIn flows:
OPENLINKS_AUTH_SESSION_TIMEOUT_MS(default600000)OPENLINKS_AUTH_SESSION_POLL_MS(default2000)
LinkedIn debug commands:
bun run linkedin:debug:bootstrapbun run linkedin:debug:loginbun run linkedin:debug:validatebun run linkedin:debug:validate:cookie-bridge
Schema: schema/site.schema.json
site.json controls display defaults, theme, interaction policy, and quality policy.
titledescriptiontheme(active,available)
Sharing controls live under top-level site.sharing.
badge.enabled: controls whether the generated site badge is published.badge.message: optional badge label override.vcard.enabled: opt-in switch for the profile-header vCard download button.vcard.filename: optional.vcfdownload filename. When omitted, runtime derives one fromprofile.name.vcard.fields: optional business-card fields. Supported keys areemail,phone,organization,title,role, andnote.vcard.include.photo: opt-in switch to embed the current profile avatar as the vCardPHOTO.vcard.include.profileUrl: includes the canonical OpenLinks profile URL by default. Set tofalseto exclude it.vcard.include.linkIds: explicit allowlist oflinks[]ids to include as extra vCard URLs.vcard.include.customUrls: extra URL entries as{ "label": "...", "url": "https://..." }.
The vCard export is deliberately conservative. Enabling the button exports FN
from profile.name and the canonical profile URL by default. Contact details,
the profile photo, and extra URLs are exported only when explicitly configured
under site.sharing.vcard. Runtime currently emits an Apple-compatible vCard
3.0 file: profile photos are embedded as inline base64 when the resolved avatar
is a supported contact image type, and labeled extra URLs use Apple grouped URL
labels so Contacts can preserve names like GitHub or Calendar.
Example:
{
"sharing": {
"vcard": {
"enabled": true,
"filename": "peter-ryszkiewicz.vcf",
"fields": {
"email": "hello@example.com",
"phone": "+15551234567",
"organization": "Example LLC",
"title": "Founder",
"role": "Engineer",
"note": "Optional business-card note"
},
"include": {
"photo": true,
"profileUrl": true,
"linkIds": ["github", "linkedin"],
"customUrls": [
{
"label": "Calendar",
"url": "https://example.com/book"
}
]
}
}
}
}The browser builds the .vcf file on demand from bundled site data and the
current canonical URL; no generated vCard asset is tracked in git.
active: selected theme idavailable: allowed theme ids
Current theme IDs are resolved by src/lib/theme/theme-registry.ts.
Main presentation controls include:
compositionMode:balanced,identity-first,links-first,links-onlygroupingStyle:subtle,none,bandsprofileRichness:minimal,standard,richmodePolicy:dark-toggle,static-dark,static-lightlinkTarget:new-tab-external,same-tab,new-tab-alldesktopColumns:one,twodensity:compact,medium,spacioustypographyScale:fixed,compact,expressivetypography: optional global/per-theme typography overridestargetSize:comfortable,compact,largecardStyle:standard(default) orglassy;glassyapplies subtle translucent, glossy, and reduced-motion-aware effects to public simple, rich, and payment link cards onlyprofileAvatarScale: number between0and4; default1.5(avatar size multiplier)profileHeaderAlignment:leading,center, or{ "default": "leading", "small": "center" }; when omitted, defaults to leading alignment on larger screens and centered alignment at the existingmax-width: 760pxmobile breakpointbrandIcons.colorMode:brand,themebrandIcons.contrastMode:auto,always-theme,always-brandbrandIcons.minContrastRatio: number between1and21(default3)brandIcons.sizeMode:normal,largebrandIcons.iconOverrides: optional known-site alias remap map ({ "x": "twitter" })richCards.imageFit:contain(default preserve mode),coverrichCards.descriptionSource:fetched(default),manualrichCards.descriptionImageRow.default:auto(default) orofffor extra rich-profile preview mediarichCards.descriptionImageRow.sites: optional override map keyed by exact hostnames or known site ids such assubstackrichCards.descriptionImageRow.placement.default:top-banner(default) orbottom-rowrichCards.descriptionImageRow.placement.sites: optional placement override map keyed likedescriptionImageRow.sitesrichCards.descriptionImageRow.bannerMinAspectRatio: numeric banner cutoff (default2)richCards.descriptionImageRow.nonBannerFallback.default:off(default) orcompact-endrichCards.descriptionImageRow.nonBannerFallback.sites: optional fallback override map keyed likedescriptionImageRow.sitesrichCards.mobile.imageLayout: legacyinline/full-widthsetting retained for backward compatibility; unified non-payment card layout now ignores itrichCards.enrichment.publicCachePath: path to committed public rich-cache manifestrichCards.enrichment.authenticatedCachePath: path to authenticated rich-cache manifestrichCards.enrichment.authenticatedCacheWarnAgeDays: stale warning threshold for authenticated cache entriesrichCards.enrichment.failureMode:immediate(default),aggregaterichCards.enrichment.failOn: blocking reasons (fetch_failed,metadata_missing)richCards.enrichment.allowManualMetadataFallback: use manual metadata as warning-level fallback when remote metadata is missingpayments.qr.displayDefault:always,toggle,hiddenpayments.qr.styleDefault:square,rounded,dotspayments.qr.foregroundColorDefault,payments.qr.backgroundColorDefault: optional site-wide overrides; when omitted, runtime defaults follow the active theme using--text-primaryand--surface-panelpayments.qr.logoModeDefault:rail-default,custom,nonerail-defaultpreserves the single-rail fallback when no site/company identity resolves, while explicit per-raillogoModestill overrides the newer implicit composite defaultpayments.qr.logoSizeDefaultpayments.qr.fullscreenDefault:enabled,disabledpayments.effects.enabledDefault: opt payment cards into special effects by defaultpayments.effects.defaultEffects: optional site-wide effect list (particles,lightning-particles,glitter-particles)payments.effects.glitterPaletteDefault:gold,icefooter.description: optional descriptive footer textfooter.ctaLabel: optional CTA button labelfooter.ctaUrl: optional CTA target URL (defaults to the canonical upstream OpenLinks GitHub repository URL; explicit values still override)footer.prompt.enabled: toggle the bootstrap prompt card in the footerfooter.prompt.title: optional prompt section titlefooter.prompt.explanation: optional short explanation above the prompt textfooter.prompt.text: optional copyable bootstrap prompt text (defaults to the canonical generated bootstrap prompt with absolute GitHub doc URLs for this repository)footer.showBuildInfo: toggle the footer build-provenance row (Built <UTC>plus an optional commit link)footer.showLastUpdated: legacy alias forfooter.showBuildInfo
Maintainers should keep the markdown OpenClaw prompt snippets synchronized with bun run openclaw:prompts:sync and verify drift with bun run openclaw:prompts:check.
Rich-card policy settings live under ui.richCards.
Description selection precedence for rich links:
links[].metadata.descriptionSourcesite.ui.richCards.descriptionSource- default
fetched
fetched resolves descriptions as metadata.description -> links[].description -> URL/domain fallback.
manual resolves descriptions as links[].description -> metadata.description -> URL/domain fallback.
Controls how rich-card preview images fill media tiles.
contain(default): preserves full image content and avoids clipping.cover: fills the media tile and may crop edges.
Resolution precedence:
links[].metadata.imageFitsite.ui.richCards.imageFit- fallback default:
contain
Migration note: previous behavior was effectively crop-first (cover). If you want that look globally, set site.ui.richCards.imageFit to cover.
Controls optional preview media for rich profile cards when metadata.image is distinct from metadata.profileImage.
defaultauto(default): enable profile preview media when the card is rich, profile-oriented, and has a distinct preview imageoff: never render the extra preview media
sites- optional override map keyed by exact hostnames (
peter.ryszkiewicz.us) or known site ids (substack,github,medium)
- optional override map keyed by exact hostnames (
placement.defaulttop-banner(default): render banner-shaped preview images above the avatar/title blockbottom-row: preserve the legacy full-width media row after the description
placement.sites- optional override map keyed like
sites
- optional override map keyed like
bannerMinAspectRatio- default
2 - preview images below the cutoff are treated as non-banner media
- default
nonBannerFallback.defaultoff(default): hide non-banner preview images whenplacement.defaultresolves totop-bannercompact-end: render non-banner preview images as a compact end-of-card tile in the content column
nonBannerFallback.sites- optional override map keyed like
sites
- optional override map keyed like
Resolution precedence:
- Exact hostname key from the link URL or host-like source label
- Known site id from the link icon/URL
site.ui.richCards.descriptionImageRow.default
Additional notes:
- The preview media is rich-card only; simple cards ignore it.
richCards.imageTreatment: "off"also suppresses it.top-bannerqualification is runtime-only and uses the image's natural width/height ratio.- Cards without a distinct preview image continue to render only the avatar/header plus description/footer flow.
Legacy mobile rich-card image placement setting retained for backward-compatible config/schema support.
inlinefull-width
Current runtime behavior: unified non-payment cards always use the shared lead-left layout across breakpoints, so this setting and links[].metadata.mobileImageLayout no longer affect rendering.
- SEO image candidates (
socialImageFallback, defaults, profile overrides) are included inimages:sync. - Starter config uses a generated local site preview at
/generated/seo/social-preview.png, produced bybun run social:preview:generate. - Runtime SEO tags (
og:image,twitter:image) use baked local assets when available. - Runtime never falls back to remote SEO image URLs; unresolved remote candidates fall back to
/openlinks-social-fallback.png.
ui.brandIcons.colorMode controls icon tinting strategy for known-site logos:
brand: uses the registry brand color (default)theme: uses theme text/icon color
ui.brandIcons.contrastMode controls fallback behavior:
auto: preserve brand where possible, then auto-adjust to satisfy contrast targetalways-theme: always use theme-driven glyph coloralways-brand: always force brand glyph color
ui.brandIcons.minContrastRatio sets the contrast target for icon glyphs against icon chip backgrounds.
ui.brandIcons.sizeMode controls default icon scale:
normal: baseline icon sizelarge: moderately larger icons (default)
ui.brandIcons.iconOverrides lets you remap known-site icon identities globally. Remapping applies after base icon resolution and is single-step only.
Example:
{
"ui": {
"brandIcons": {
"iconOverrides": {
"x": "twitter"
}
}
}
}Footer content and CTA are configurable from data/site.json.
description: descriptive body copy shown in the footer.ctaLabel: button text for the footer CTA.ctaUrl: CTA destination URL (when omitted, runtime useshttps://github.com/pRizz/open-links; explicit values still override).prompt.enabled: controls whether the footer renders the copyable bootstrap prompt card.prompt.title: heading shown above the bootstrap prompt.prompt.explanation: short helper copy explaining how to use the prompt.prompt.text: copyable prompt text shown in the footer compact field for single-line values, with a preformatted fallback for multiline values (when omitted, runtime uses repo-aware absolute GitHub doc URLs).showBuildInfo: controls rendering of the footer build-provenance row (Built <UTC>plus an optionalCommit <shortSha>link).showLastUpdated: legacy alias forshowBuildInfo.showBuildInfowins when both are present.
This fallback is renderer-level only: downstream consumers such as open-links-sites
keep the same data contract, but sites that omit footer.ctaUrl inherit the canonical
upstream repository CTA by default.
Example:
{
"ui": {
"footer": {
"description": "OpenLinks is a personal, free, open source links site. It stores no cookies, and we will never serve ads. Welcome to the new Open Web, where privacy and freedom reign.",
"ctaLabel": "Create Your OpenLinks",
"prompt": {
"enabled": true,
"title": "Create your own OpenLinks site",
"explanation": "Paste this bootstrap prompt into OpenClaw, Claude, or Codex to create a new OpenLinks site from this repository."
},
"showBuildInfo": true
}
}
}The footer build-provenance row and /build-info.json expose the same runtime fields:
builtAtIso, commitSha, commitShortSha, and commitUrl.
ui.typography provides data-driven typography overrides without editing CSS files.
Precedence order:
- Built-in token defaults
ui.typographyScalepreset baseline (fixed,compact,expressive)ui.typography.globalui.typography.themes[theme.active]
Supported keys in each override object:
- Font families:
fontDisplay,fontBody - Type sizes:
sizeTitle,sizeHeadline,sizeBody,sizeCaption,sizeCardTitle,sizeLinkTitle,sizeIcon - Line heights:
lineHeightTitle,lineHeightBody,lineHeightCardTitle,lineHeightCardDescription - Font weights:
weightCardTitle,weightLinkTitle,weightIcon - Letter spacing:
trackingUtilityTitle,trackingSectionHeading,trackingCardSource,trackingIcon - Text transforms:
transformUtilityTitle,transformSectionHeading,transformContactLabel
Valid transform values:
noneuppercaselowercasecapitalize
Example:
{
"ui": {
"typographyScale": "compact",
"typography": {
"global": {
"fontBody": "\"IBM Plex Sans\", \"Segoe UI\", sans-serif",
"sizeBody": "1.02rem",
"lineHeightBody": 1.6
},
"themes": {
"editorial": {
"fontDisplay": "\"Fraunces\", \"Iowan Old Style\", serif"
}
}
}
}
}Quality controls include:
- report output paths
- blocking domains
- SEO defaults/overrides
- accessibility smoke-check labels
- performance profile budgets
These values are consumed by:
scripts/quality/run-quality-checks.tsscripts/quality/seo.tsscripts/quality/a11y.tsscripts/quality/perf.ts
bun run validate:dataBehavior:
- fails on errors
- allows warnings
bun run validate:data:strictBehavior:
- fails on errors
- fails on warnings
bun run validate:data:jsonBehavior:
- machine-readable output for scripts/agents
Validator output includes source file, JSON path, issue type, and remediation.
[data/links.json] $.links[1].url: URL scheme 'ftp:' is not allowed.
Fix: Use one of: http, https, mailto, tel.
Action:
- Open
data/links.json. - Navigate to
links[1].url. - Replace with supported scheme.
- Re-run validation.
[data/site.json] $.experimentalFlag: Unknown top-level key 'experimentalFlag' is allowed but not part of the core contract.
Fix: Move 'experimentalFlag' into a dedicated custom block if it is extension data, or document why it belongs at top level.
Action:
- Move extension to
site.custom.experimentalFlag. - Re-run standard or strict validation.
OpenLinks supports extensions through custom, but there are guardrails.
profile.customlinks.customsite.customlinks[].customlinks[].metadata.customlinks[].enrichment.custom
- Use descriptive prefixes for project-specific fields.
- Keep extension values serializable JSON.
- Document custom keys in your fork README or docs.
- Reuse reserved core keys (
title,theme,type, etc.) insidecustomat the same object level. - Put required core behavior behind undocumented custom flags.
- Depend on unknown top-level keys long-term.
{
"custom": {
"title": "Collides with reserved core key"
}
}{
"custom": {
"projectTitleOverride": "Custom semantic key"
}
}You can use these ready presets directly:
data/examples/minimal/for quick launch.data/examples/grouped/for grouped + ordered links.data/examples/invalid/for testing validation and CI checks.
For most maintainers, prefer the AI-assisted or Studio paths above and use the loop below only when you intentionally choose the manual fallback path.
- Update JSON in
data/. - Run
bun run validate:data. - Run
bun run build. - Preview with
bun run preview. - Commit and push.
- Root overview:
README.md - Fast setup and deployment path:
docs/quickstart.md - AI-assisted change flow:
docs/ai-guided-customization.md - Exhaustive customization checklist:
docs/customization-catalog.md - Social-card verification guide:
docs/social-card-verification.md - Authenticated extractor architecture/workflow:
docs/authenticated-rich-extractors.md - New extractor implementation workflow:
docs/create-new-rich-content-extractor.md