Skip to content

16 — acquisition / SEO / social (Design)

16 — acquisition / SEO / social (Design)

Section titled “16 — acquisition / SEO / social (Design)”

Status: Binding Date: 2026-06-19 Owner: acquisition / growth (subsystem 16) Depends on: 07 public-site-archive (SEO lib, OG, sitemap), 09 engagement-features (@nowyourlink/pixel, Analytics Engine METRICS), 12 legal-compliance (packages/consent, packages/legal), 13 quality-and-release-policy (§10 recipes); the cron dispatcher apps/cron/src/scheduled.ts (#751) and the email adapter pattern packages/email. Binding refs: master decision log D44–D48 (added by this spec), epic #764 + the free/OSS research brief comment, tracks 16-A…16-F (#765/#770), Phase 1 tracker #817.


Goal. Build the acquisition machine for nowyourlink — the reusable spines that bring strangers in via search engines, social platforms, and content — for €0 of recurring SaaS spend, Cloudflare-native, with a human approval gate before anything publishes. This spec consolidates the decisions already captured across epic #764 and tracks 16-A…16-F into one binding reference; implementers cite this spec, not the issues.

In scope (subsystem 16 owns):

  • The social-publishing spine: @nowyourlink/social-adapters (adapter contract
    • service) and the q-social-publish queue consumer.
  • Network adapters: Mastodon, Bluesky (Phase 1); Telegram (Phase 2).
  • Technical-SEO enrichment on apps/web: typed JSON-LD (schema-dts), sitemap hardening, IndexNow ping, and the CI SEO guardrails that stop regressions.
  • First-party, cookieless analytics on the existing Analytics-Engine METRICS binding; AI-assisted content drafting (Workers AI / Claude) behind a review gate; new apps/cron lanes that schedule the above.
  • The 16-F human+AI operating layer (Claude Code skills / runbooks / a scheduled review-only GitHub Action).

Non-goals:

  • Retention / lifecycle CRM — that is subsystem 15 (#722): push + email to people we already have, consent-gated DOI. Subsystem 16 does not duplicate it; it reuses the shared primitives (D45).
  • Paid acquisition of any kind (paid social, paid SEO tools, paid rank trackers). Any tool that is not free/OSS is out (D44).
  • Auto-publishing. Nothing — social, AI content, or analytics-derived copy — reaches a public surface without a human approval step (D47).
  • Changing the sealed-bid auction model, the WS protocol, or any subsystem-15 consent/legal decision. This spec consumes those, it does not amend them.

2. Tooling decisions (license + free-tier)

Section titled “2. Tooling decisions (license + free-tier)”

Every choice below is free and/or open source. Licenses + free-tier limits are stated so we never silently take on recurring cost (master D44). This table is the canonical version of the #764 research brief; the brief is the evidence, this is the binding selection.

NetworkPhaseFree to post?Auth / gateFree limitNotes
MastodonP1✅ fullyOAuth2 token, self-serve, no review300 req / 5 min / acctPlug-and-post; POST /api/v1/statuses (Idempotency-Key header).
Bluesky (AT-proto)P1✅ fullyApp-password / OAuth, no review~1,666 creates/hrNeeds link facets (byte-range index) for clickable URLs.
Telegram channelP2✅ fullyBot token; bot is channel admin~20 msg / minsendMessage; trivial once bot is admin.
Facebook / Instagram / Pinterestlaterfree, no feeMeta App Review / standard-access reviewvariesHeavy approval; deferred.
Reddit❌ outcommercial ≈ $12k/yr+——Commercial licensing risk — excluded.
X / Twitter❌ out❌ pay-per-use——$0.015/post + $0.20 per link post — worst economics for a link product — excluded.

Decision (D44 applies): Phase 1 ships Mastodon + Bluesky; Telegram is the first Phase 2 follow-up. X and Reddit are permanently out on cost/licensing.

  • Structured data — schema-dts (Apache-2.0): compile-time-checked JSON-LD types; correctness enforced by Vitest parsing the application/ld+json block (no hosted validator exposes a usable CI API). ⚠️ Google is sunsetting FAQ rich results (~mid-2026) — keep FAQPage markup (still valid) but do not treat FAQ rich-result eligibility as a goal.
  • Sitemaps: keep the hand-rolled SSR sitemap.xml.ts (@astrojs/sitemap only emits build-time routes). Validate against sitemaps.org XSDs with xmllint in CI. Dynamic-URL helper: sitemap (ekalinin, MIT) if needed.
  • Lighthouse CI (treosh/lighthouse-ci-action, MIT) against apps/web/dist; numberOfRuns: 3, median, SEO-category budget assertion. Site-wide crawl audit via Unlighthouse (MIT) from the 16-F skill.
  • IndexNow (free protocol): one POST → Bing/Yandex/Seznam/Yep. ⚠️ Google does not participate — sitemap + crawl remain Google’s path. Ping from the app (not Cloudflare’s account-level toggle) so previews don’t leak.
  • Search performance: Google Search Console API (free; service-account; ⚠️ 16-month retention → persist to D1) + Bing Webmaster API (free; key). Ingestion lands in Phase 2 (16-D/16-E), not Phase 1.
  • Links: lychee (Apache-2.0, Rust) — internal blocking check in CI, external rot on a scheduled lane.
  • Rank tracking: prefer free GSC/Bing position data. SerpBear (MIT) is optional and deferred (relies on a paid/legally-grey SERP backend).
  • Selected: first-party Workers + Analytics Engine collector on the already-bound METRICS dataset (cron worker binds it today). Cookieless, €0, no extra service. Strictly anonymous — see §4.
  • Umami / Plausible CE / Matomo / PostHog self-host are heavier and not adopted (each needs an extra datastore/container).
  • Workers AI (free daily Neuron allowance, Llama-class) is the default, €0 generation path for all high-volume product content (post copy, alt-text, meta descriptions) — this is what the runtime/cron lanes call.
  • Claude API / claude-code-action is the human-operated dev/ops tool (the team already runs Claude Code across this repo) for occasional higher-quality long-form + the scheduled growth routines. It is not a product-runtime dependency and is exempt from the €0 product-cost line — its token usage is dev-tooling spend, not recurring SaaS the product needs to operate, and it always sits behind the human approval gate (D47). See the §2.6 cost-boundary note and the epic’s constraint #3 (“AI-in-the-loop, Claude-triggerable”).

2.6 The €0 cost boundary (what D44 does and does not forbid)

Section titled “2.6 The €0 cost boundary (what D44 does and does not forbid)”

D44 forbids recurring third-party SaaS the product depends on to operate — no paid social APIs, no paid SEO/rank-tracker subscriptions, no paid analytics service. It does not forbid the team’s existing human-operated dev/ops tooling (Claude Code / claude-code-action), which the repo already uses for development and which here only produces review artifacts behind a human gate (D47), never an always-on product code path. The product’s own AI content path is Workers AI’s free tier (§2.4). Any tool that would put recurring spend on the product’s runtime is out (§2.1 X/Reddit are the worked examples).

Owner-approved exception (#1071, 2026-09-17): the weekly search-query intent classifier may use TypeSafe when the cron key is provisioned. It is optional, capped at 500 previously unseen queries per run, and never gates search ingestion, public routes, or the growth report. The operator checks metered spend before provisioning; the issue’s cost estimate is not a budget guarantee.

Owner-requested conditional path (#1068, 2026-09-28): generated recaps and alt text may use a separate TYPESAFE_CONTENT_API_KEY to check claims before storage. Without it, they use deterministic text; public routes do not depend on the check. Provision only after an operator reviews expected metered spend. The text-only check cannot prove alt text matches an image, so human review remains required.

  • Reuse Resend (already the transactional provider, D14) Broadcasts before standing up anything new. Listmonk (AGPL) only if we outgrow Resend — out of Phase 1.

3.1 The social-publishing spine (@nowyourlink/social-adapters + q-social-publish)

Section titled “3.1 The social-publishing spine (@nowyourlink/social-adapters + q-social-publish)”

Mirrors packages/email exactly: types → service → adapters/ (+ a mock double), with the queue consumer mirroring the dispatch cron #751 (claim-then-send + idempotency). Nothing in this spine auto-publishes — the queue is fed only from human-approved drafts (§4, D47).

packages/social-adapters/src/
types.ts # SocialPost, SocialAdapter, PublishResult, NetworkId
service.ts # createSocialPublisher({ adapters, logger })
adapters/
mastodon.ts # POST /api/v1/statuses (Idempotency-Key)
bluesky.ts # com.atproto.repo.createRecord (+ link facets)
mock.ts # in-memory double — the ONLY adapter used in tests
tests/
apps/queue/ (or a q-social-publish consumer module)
q-social-publish # claim-then-send + idempotency, mirrors #751

Contract (binding shape — #778 implements, #779/#780 conform):

export type NetworkId = "mastodon" | "bluesky" | "telegram";
export interface SocialPost {
/** Caller-supplied idempotency key; the network idempotency primitive. */
readonly idempotencyKey: string;
/** Plain-text body. Adapters own per-network length/format limits. */
readonly text: string;
/** Canonical link to share (the day page / archive entry). Optional. */
readonly link?: string;
/** Image/alt for networks that support media; adapters MAY ignore. */
readonly media?: ReadonlyArray<{ readonly url: string; readonly alt: string }>;
}
export interface PublishError {
readonly code: string; // e.g. "rate_limited", "auth", "network"
readonly message?: string;
readonly status?: number;
readonly retryable: boolean; // drives the consumer's retry/DLQ decision
}
export type PublishResult =
| { readonly ok: true; readonly network: NetworkId; readonly remoteId: string; readonly url?: string }
| { readonly ok: false; readonly network: NetworkId; readonly error: PublishError };
export interface SocialAdapter {
readonly network: NetworkId;
publish(post: SocialPost): Promise<PublishResult>;
}

Rules for the spine:

  • Secrets via Worker bindings only (Mastodon token, Bluesky app-password, Telegram bot token). Never in code, never in the SocialPost.
  • No live network in tests. Every adapter ships with a fetchImpl injection seam (cf. mailpit.ts); unit tests inject a mock fetch, and the end-to-end spine test uses adapters/mock.ts. This is non-negotiable (D48).
  • Idempotency at two layers: the consumer claims-then-sends (one delivery per idempotencyKey, mirroring #751); adapters additionally pass the key to the network primitive where one exists (Mastodon Idempotency-Key).
  • retryable is adapter-owned: rate-limit/5xx → retryable: true (consumer re-queues with backoff); auth/validation → retryable: false (→ DLQ + alert).

Build on apps/web/src/lib/seo.ts — do not fork it. Phase 1 adds:

  • Typed JSON-LD (#772): introduce schema-dts and re-express the JSON-LD builders against schema-dts types, keeping the existing SafeJsonLd branded-string output so the .astro SeoHead shell is unchanged. New product schema (Product/Offer, Event, BreadcrumbList for the Spotlight & /day/[n]) lands here as typed builders with 100% Vitest coverage. (Superseded in 16-A by #941: the per-block builders were consolidated into a single pageGraphJsonLd assembler emitting ONE linked @graph per page — Organization/WebSite/WebPage plus the page’s primary nodes joined via @id references, adapted from the jdevalk/seo-graph pattern, MIT, no dependency taken.)
  • IndexNow ping on archive/day publish (Phase 2 wiring; the helper + key route may land in Phase 1 if cheap).
  • Sitemap hardening (index + per-locale + media children, real lastmod from D1) — primarily Phase 2 (16-A), gated by the same CI checks.

After #772 establishes the typed authoring pattern, gate it:

  • Lighthouse-SEO budget — extend the existing perf-budget job in .github/workflows/lighthouse-ci.yml with an SEO-category budget assertion (numberOfRuns: 3, median). Target the deployed preview URL (https://preview.nowyourlink.com/de), not a static apps/web/dist: apps/web is output: "server" (a Cloudflare Worker, spec 07 §2) and cannot be served from a static dist, so the existing job audits the remote preview over the network — the SEO gate must follow that same pattern. (The static-dist path is only valid for apps/docs-site’s docs-landing job.) The job stays gated on a successful Preview deploy (workflow_run), as today.
  • JSON-LD assertions — Vitest tests parse the rendered ld+json blocks and assert required schema.org shapes (Article/Product/Offer/Event/Breadcrumb/ FAQ) using schema-dts types. These run inside pnpm verify.
  • Sitemap XSD validation — xmllint against sitemaps.org XSDs.
  • Link check — lychee internal links blocking in CI.

These are the SEO CI gates named in the Phase 1 exit criteria (#817).

3.4 Analytics collector (Phase 2 detail, decided here)

Section titled “3.4 Analytics collector (Phase 2 detail, decided here)”

A first-party Worker route writes anonymous events to the Analytics-Engine METRICS dataset (no cookies, no PII — §4). No new datastore. The collector and its dashboards land in Phase 2 (16-D); the decision (Analytics Engine, not Umami/Plausible) is locked here as D46.

Workers AI / Claude drafts copy (post text, alt-text, meta descriptions); the draft lands in an admin/PR review artifact; a human approves; only then is a SocialPost enqueued to q-social-publish. The pipeline has no path from model output to a public surface that skips the human gate (D47). The 16-F skill social-post-compose is the front door (draft → human review → enqueue).

New scheduled lanes (social cadence, lychee external rot sweep, GSC/Bing ingestion) are added to apps/cron/src/scheduled.ts’s controller.cron dispatch — one siloed handler per lane, mirroring the existing close/precreate/ salt-rotation pattern. No new cron worker. Phase 2.


Reuse subsystem-15 / legal primitives; do not duplicate them (master D45). The relevant primitives are packages/consent (ConsentPurpose/ConsentState), packages/legal, and the existing consent records (#727/#739).

Reconciliation with D43 (the binding constraint): D43 removed the only non-essential-cookie vendor (Disqus), so the site currently needs no cookie-consent banner and no consent log. Subsystem 16 must preserve that posture:

  1. Analytics stays strictly anonymous & cookieless (Analytics Engine, §3.4): no cookies, no device/user identifiers, no cross-site joins. Under that constraint no consent banner is required (master D46). If any future analytics need would identify a user, it goes behind the existing consent primitive first and requires legal sign-off — it does not silently reintroduce a banner.
  2. Social publishing posts to our own channels about our own auctions — it processes no personal data of the strangers we are trying to reach, so it raises no new data-subject obligation.
  3. AI content is human-reviewed (D47); no personal data is fed to models as part of the drafting flow.
  4. Lifecycle/marketing email (newsletter via Resend Broadcasts) is retention (subsystem 15) and stays behind its existing DOI consent (#727/#739) — subsystem 16 reuses it, never forks it.

Net: Phase 1 introduces no new consent surface and no new cookie. Coordinate any deviation with subsystem/12-legal before it ships.


Per master D39, every sub-system carries a spec 13 §10.2 skill recipe that implementation agent teams MUST follow. Subsystem 16’s recipe entry is owned by track 16-F (#770) and is added to spec 13 §10.2 alongside the existing per-sub-system recipes (mirroring the #755 precedent). Summary of the recipe this spec binds:

  • Per issue: branch off develop (16-<area>-p<n>-<slug>), one PR per issue ([16-x][Pn] …, never main), workflows:tdd-cycle (red → green → refactor).
  • Reuse, don’t fork: packages/email (adapter spine), apps/cron/src/ scheduled.ts (cron lanes), apps/web/src/lib/seo.ts (SEO), @nowyourlink/ pixel + METRICS (analytics), consent #727/#739 and dispatch #751.
  • Before merge: pnpm verify green (lint + typecheck + 100% coverage + build), zero warnings; superpowers:requesting-code-review + pr-review-toolkit:review-pr; CODEOWNERS reviewers where required; tick the box on the parent epic / Phase-1 tracker #817.
  • Skills (16-F): seo-audit (Unlighthouse/lychee/JSON-LD vs a preview — authored at .claude/skills/seo-audit/SKILL.md + runbook docs/superpowers/ runbooks/seo-audit.md, #797), social-post-compose (draft → review → enqueue), weekly growth report; the scheduled GitHub Action .github/workflows/growth-routines.yml (#800) runs weekly-growth-report + seo-audit headless on a weekly cadence and produces review artifacts only — its job token’s only repository-write scope is issues: write (contents: read for checkout; id-token: write is a non-repo OIDC mint), so it is structurally unable to push, open/merge a PR, or publish; it can only open a labelled review-artifact issue. The subsystem-16 row now exists in spec 13 §10.2.

6. Phase 1 scope & exit criteria (tracker #817)

Section titled “6. Phase 1 scope & exit criteria (tracker #817)”

Phase 1 builds the dependency roots only — issues #771, #778, #782, #772, #776, #779, #780:

  1. #771 — this spec (do first; blocks the rest).
  2. #778 → #782 — adapter interface + service, then the q-social-publish consumer.
  3. #772 → #776 — typed JSON-LD, then the CI SEO gates.
  4. #779 + #780 — Mastodon + Bluesky adapters (after #778).

Exit criteria (unblock Phase 2):

  1. This spec merged and referenced from #764; decision log updated (D44–D48).
  2. An approved SocialPost flows interface → q-social-publish → Mastodon/Bluesky in a test (against the mock double — no live network).
  3. SEO CI gates (Lighthouse-SEO budget + JSON-LD assertions) active.

Phase 2 then picks up Telegram (#781), sitemap/IndexNow (#774/#775), AI recap (#784), analytics (#789/#790/#791), the cron lanes, and the Claude ops layer.

The consolidated per-phase acceptance criteria (all phases, with evidence + status) and the deferred-capabilities list live in the acceptance companion 2026-06-19-16-acceptance.md (M3-equivalent, #801).


7. Decision-log additions (applied to master design)

Section titled “7. Decision-log additions (applied to master design)”

This spec adds the following entries to the master decision log (2026-05-23-nowyourlink-master-design.md §9):

  • D44 — Acquisition tooling is free/OSS only, €0 recurring; X and Reddit excluded on cost/licensing; social MVP = Mastodon + Bluesky (+ Telegram P2).
  • D45 — Acquisition is subsystem 16, separate from retention subsystem 15; the two share consent (#727/#739), dispatch (#751), and admin-audit and must not duplicate them.
  • D46 — First-party Analytics Engine collector for organic analytics, strictly anonymous & cookieless → no consent banner (preserves D43); any user-identifying analytics goes behind existing consent first.
  • D47 — Human approval gate before publish for all social + AI content; no model-output → public-surface path skips review; no auto-merge/auto-publish.
    • Amendment (owner decision, 2026-09-29): blog articles in the EmDash hub (nyl_articles) go live without a human step. Only the hub’s automatic quality gate publishes them; write agents never publish. The gate applies the checks in .claude/skills/emdash-content-publish/SKILL.md. D47 stays in force for social posts and all other AI content.
  • D48 — No live network in subsystem-16 tests; every adapter/integration ships a mock/local double (cf. mailpit.ts); 100% coverage per D32.