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.
1. Goals & non-goals
Section titled “1. Goals & non-goals”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-publishqueue consumer.
- service) and the
- 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
METRICSbinding; AI-assisted content drafting (Workers AI / Claude) behind a review gate; newapps/cronlanes 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.
2.1 Social posting
Section titled “2.1 Social posting”| Network | Phase | Free to post? | Auth / gate | Free limit | Notes |
|---|---|---|---|---|---|
| Mastodon | P1 | ✅ fully | OAuth2 token, self-serve, no review | 300 req / 5 min / acct | Plug-and-post; POST /api/v1/statuses (Idempotency-Key header). |
| Bluesky (AT-proto) | P1 | ✅ fully | App-password / OAuth, no review | ~1,666 creates/hr | Needs link facets (byte-range index) for clickable URLs. |
| Telegram channel | P2 | ✅ fully | Bot token; bot is channel admin | ~20 msg / min | sendMessage; trivial once bot is admin. |
| Facebook / Instagram / Pinterest | later | free, no fee | Meta App Review / standard-access review | varies | Heavy approval; deferred. |
| ❌ out | commercial ≈ $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.
2.2 SEO tooling
Section titled “2.2 SEO tooling”- Structured data —
schema-dts(Apache-2.0): compile-time-checked JSON-LD types; correctness enforced by Vitest parsing theapplication/ld+jsonblock (no hosted validator exposes a usable CI API). ⚠️ Google is sunsetting FAQ rich results (~mid-2026) — keepFAQPagemarkup (still valid) but do not treat FAQ rich-result eligibility as a goal. - Sitemaps: keep the hand-rolled SSR
sitemap.xml.ts(@astrojs/sitemaponly emits build-time routes). Validate against sitemaps.org XSDs withxmllintin CI. Dynamic-URL helper:sitemap(ekalinin, MIT) if needed. - Lighthouse CI (
treosh/lighthouse-ci-action, MIT) againstapps/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).
2.3 Analytics
Section titled “2.3 Analytics”- Selected: first-party Workers + Analytics Engine collector on the
already-bound
METRICSdataset (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).
2.4 AI content
Section titled “2.4 AI content”- 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-actionis 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.
2.5 Newsletter
Section titled “2.5 Newsletter”- 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. Architecture
Section titled “3. Architecture”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 #751Contract (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
fetchImplinjection seam (cf.mailpit.ts); unit tests inject a mockfetch, and the end-to-end spine test usesadapters/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 (MastodonIdempotency-Key). retryableis adapter-owned: rate-limit/5xx →retryable: true(consumer re-queues with backoff); auth/validation →retryable: false(→ DLQ + alert).
3.2 SEO enrichment (apps/web)
Section titled “3.2 SEO enrichment (apps/web)”Build on apps/web/src/lib/seo.ts — do not fork it. Phase 1 adds:
- Typed JSON-LD (#772): introduce
schema-dtsand re-express the JSON-LD builders againstschema-dtstypes, keeping the existingSafeJsonLdbranded-string output so the.astroSeoHeadshell 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 singlepageGraphJsonLdassembler emitting ONE linked@graphper page — Organization/WebSite/WebPage plus the page’s primary nodes joined via@idreferences, 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
lastmodfrom D1) — primarily Phase 2 (16-A), gated by the same CI checks.
3.3 CI SEO guardrails (#776)
Section titled “3.3 CI SEO guardrails (#776)”After #772 establishes the typed authoring pattern, gate it:
- Lighthouse-SEO budget — extend the existing
perf-budgetjob in.github/workflows/lighthouse-ci.ymlwith an SEO-category budget assertion (numberOfRuns: 3, median). Target the deployed preview URL (https://preview.nowyourlink.com/de), not a staticapps/web/dist:apps/webisoutput: "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 forapps/docs-site’sdocs-landingjob.) The job stays gated on a successful Preview deploy (workflow_run), as today. - JSON-LD assertions — Vitest tests parse the rendered
ld+jsonblocks and assert required schema.org shapes (Article/Product/Offer/Event/Breadcrumb/ FAQ) usingschema-dtstypes. These run insidepnpm verify. - Sitemap XSD validation —
xmllintagainst sitemaps.org XSDs. - Link check —
lycheeinternal 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.
3.5 AI draft → review → publish flow
Section titled “3.5 AI draft → review → publish flow”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).
3.6 New apps/cron lanes
Section titled “3.6 New apps/cron lanes”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.
4. Consent / DSGVO posture
Section titled “4. Consent / DSGVO posture”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:
- 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.
- 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.
- AI content is human-reviewed (D47); no personal data is fed to models as part of the drafting flow.
- 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.
5. Agent-team / skill recipe pointer
Section titled “5. Agent-team / skill recipe pointer”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] …, nevermain),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 verifygreen (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+ runbookdocs/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) runsweekly-growth-report+seo-auditheadless on a weekly cadence and produces review artifacts only — its job token’s only repository-write scope isissues: write(contents: readfor checkout;id-token: writeis 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:
- #771 — this spec (do first; blocks the rest).
- #778 → #782 — adapter interface + service, then the
q-social-publishconsumer. - #772 → #776 — typed JSON-LD, then the CI SEO gates.
- #779 + #780 — Mastodon + Bluesky adapters (after #778).
Exit criteria (unblock Phase 2):
- This spec merged and referenced from #764; decision log updated (D44–D48).
- An approved
SocialPostflows interface →q-social-publish→ Mastodon/Bluesky in a test (against themockdouble — no live network). - 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.
- Amendment (owner decision, 2026-09-29): blog articles in the EmDash hub (
- D48 — No live network in subsystem-16 tests; every adapter/integration
ships a
mock/localdouble (cf.mailpit.ts); 100% coverage per D32.