| name | setup-client-website |
| description | One-time onboarding for a new consumer site: SEO audit of the existing site, Next.js 16 scaffold, @m13v/seo-components install, route-group architecture, real images/video/structured data, Cloud Run deploy, dashboard registration, and SEO infrastructure wiring (withSeoContent, guide index, optional sidebar and AI chat). This skill stops at infrastructure; day-to-day SEO guide page generation is handled by the gsc-seo-page skill and seo/generate_page.py. Use when: 'set up client website', 'onboard new client site', 'new consumer site', 'recreate website', 'rebuild website', or when spinning up a fresh site that will later receive programmatic SEO pages. |
| user_invocable | true |
Setup Client Website
One-time onboarding flow for a new (or rebuilt) client/consumer site. Produces a modern, SEO-optimized Next.js site with real content, images, video embeds, structured data, and the infrastructure needed for programmatic SEO guide pages.
Scope boundary: this skill leaves the repo in a state where /t/<slug> guide pages can be produced. It does NOT write guide pages. All guide-page content generation is handled by the gsc-seo-page skill, which drives ~/social-autoposter/seo/generate_page.py. After running this skill, register the site in ~/social-autoposter/config.json and hand off to gsc-seo-page for every page.
Warm-start handoff: when the client has an existing blog, Phase 1c.1 produces research/client-blog-inventory.json, a seed list of unbranded category queries derived from the client's existing posts. Phase 9 upserts it into seo_keywords with source = 'client_blog' before the cron activates, so the first 20 to 50 guide pages target topics the client has already invested editorial work into rather than cold DataForSEO discovery. The inventory captures topics and traffic signals only, never body copy: every /t/<slug> page is still a substantially longer, unbranded rewrite written by gsc-seo-page.
Business model: we own the funnel end-to-end
We build a separate SEO site alongside the client's brand site because we own this new domain, the traffic it earns, and the conversion path that ends at our booking link (or our tracked get-started URL). The client keeps their brand site; we route organic visitors through a tracked CTA on a domain we control until the booking fires. That is the whole business model: we are paid for bookings, not for traffic, so every visitor who leaves to the client's brand or product domain before the CTA fires is revenue handed back for free.
Hard rule: no marketing CTA ever points at the client's brand or product domain. Not <brand_domain>, not hire.<brand_domain>, not app.<brand_domain>, nowhere in src/app/(main)/. Privacy and terms should be written as self-contained pages on the new domain, not as stub pages that link out to the brand site's legal copy. Every tier button, hero CTA, pricing card, inline CTA, modal CTA, and footer CTA terminates on our site: /precall, BookCallLink, GetStartedLink, a modal that captures email then routes to a tracked booking, or a tracked trackAs="..." CTA. A raw <a href="https://<anything-the-client-owns>"> on a marketing page is a bug, same severity as a raw <a href="https://cal.com/..."> that bypasses withBookingAttribution. Phase 8 must grep src/app/(main)/ for anchors pointing at brand_domain (or any of its subdomains) and fail the audit on any hit.
Self-serve CTAs on a book-a-call-only site. When the research brief surfaces a self-serve product motion (pricing page, "Start free" tier, download button) but only book-a-call scope is enabled, do NOT link the self-serve CTA at the client's product app. Build an email-capture modal on our domain that collects the email, sends a welcome transactional via Resend containing the Book-a-Call link, and in the modal itself routes the user to /precall or BookCallLink. The client still gets the signup, we keep the funnel.
Hard rule, copy edition (gated-redirect on): the destination URL never appears in user-visible text either. The anchor rule above closes the link surface (no <a href> to the brand). When gated-redirect is on, the destination URL must also be absent from: body copy in any .tsx under src/, FAQ Q&A objects, footer paragraphs, modal subtitles, metadata.description, OpenGraph descriptions, JSON-LD sameAs arrays in layout.tsx, alt text, and the welcome-email HTML template inside src/app/api/signup/route.ts. The bare brand name (Jungle, Piastech) may stay where it's needed for legal/entity disclosure (privacy and terms keep <Brand> Inc. as the operating entity), but the literal URL string (app.<brand>.com, <brand>.com, even fragments like <brand>.com/terms-of-service) does not. The welcome email body sent by Resend carries the access link in an <a href={redirect}>, which is unavoidable and fine; that email is post-gate, not pre-gate. Phase 8 audits this by grepping the whole src/ tree (not just src/app/(main)/) for the brand and app URL stems and failing on any match outside the welcome-email payload. A user-visible "Where am I being redirected?" FAQ Q&A that names the destination is the most common source of leak; do not generate one.
Arguments
Provide the client name, domain (if any), and existing site URL (if any). Example: "Paperback Expert at paperbackexpert.com"
Optional scope flags
These are OFF by default. Only enable them if the invoker mentions the feature explicitly (e.g. "with book-a-call", "with get-started", "add a contact form"). If the scope flag is not mentioned, skip every phase marked [opt-in: book-a-call] / [opt-in: get-started] — do not half-scaffold placeholders.
| Flag | Default | Triggers phases |
|---|
book-a-call | off | 3.5d Book-a-Call helpers, 3.5k.1 /r/[code] short-link redirect, 3.5l Cal.com event type creation, 6g Cal.com webhook wiring, Phase 8 booking-verification row, Phase 10a booking_link field |
get-started | off | 3.5d′ Get-Started helpers, Phase 8 get-started-verification row, Phase 10a get_started_link field |
gated-redirect | off | 3.5d″ EmailGateModal + /api/signup scaffold (with mandatory Resend audience upsert + newsletter_subscribed event), server-only GET_STARTED_URL, runtime envs RESEND_API_KEY+RESEND_AUDIENCE_ID on the deploy target (NOT just .env.local), Phase 8 destination-leak audit + audience-population audit + Email-Signups dashboard audit. Requires get-started to also be on. |
Get-Started covers every self-serve primary CTA: downloads (Mac .dmg, App Store, /download), installs (/install, Chrome Web Store, browser extension listings), and signups (SaaS app.<domain> entry, waitlist, trial start). They all fire the same get_started_click PostHog event and roll into one "Get Started" column in the dashboard. Aliases like download-link or signup-link map to this flag.
Always-on (NOT behind a flag): inbound email. Every client site sends from matt@<domain> (per defaults.sender_local_part in ~/social-autoposter/config.json). Because that's a real human-named address, recipients WILL hit Reply. Every site MUST therefore wire Resend Inbound (Phase 3.5k) so replies route to defaults.inbound_forward_email (i@m13v.com by default). This is not optional regardless of which feature flags are on. Studyly 2026-05-05 shipped without inbound and we lost replies from 6 captured signups before noticing.
When a flag is off: do not add the corresponding CTA components, do not add the corresponding field to config.json, and skip the related Phase 8 checklist rows.
- Book-a-call off → no
<BookCallLink>/<BookCallTracker>, no Cal.com event type, no booking_link in config.json.
- Get-started off → no
<GetStartedLink>/<GetStartedCTA>, no get_started_link in config.json. The stats pipeline counts get_started_click per-host, so a site that does not fire this event will simply show 0 — harmless.
Both flags can be on for the same site. Fazm is the canonical example: it has a booking_link (Cal.com team URL for pilot calls) and a get_started_link (Mac app download). Assrt is another: booking_link for the pilot call plus get_started_link pointing at app.assrt.ai for self-serve signup.
Gated-redirect: when to turn it on. The default get-started scaffold renders <GetStartedLink href={GET_STARTED_URL} target="_blank">, which puts the destination URL in the DOM (href attribute, hover status bar, "copy link"). That is fine when the destination IS our domain (Fazm: fazm.cc/download) or when we don't mind users bypassing email capture (Assrt: app.assrt.ai with no separate brand domain). Turn gated-redirect ON whenever the get-started destination is on a different domain we want to keep hidden until the visitor submits an email, typically when brand_domain ≠ website AND the brand_domain is the self-serve product app (e.g. studyly.io routes to app.jungleai.com). With this flag on, every Get-Started CTA opens an email-gate modal; /api/signup collects the email, sends a Resend welcome with the access link, and returns the redirect URL via JSON for window.location.href. The destination URL never appears in HTML, body copy, FAQ, JSON-LD, or metadata.description. Studyly is the canonical example.
Rationale: free OSS tools and install-driven products (ClaudeMeter, appmaker-style utilities) have no "book a call" conversion — they need get-started instead. Enterprise pilot sites with no self-serve path need only book-a-call. Product-led sites with both a pilot offer and a self-serve entry point need both. Forcing either wiring on a site that does not match the conversion shape produces dead links and skewed funnel stats.
Prerequisites
- Google Cloud project under the org specified in
~/social-autoposter/config.json > defaults.gcp_organization_name (see 1.5e — per-client projects are created as <slug>-prod)
- GitHub org or personal account
- PostHog account (org specified in
config.json > defaults.posthog_org) for analytics
- Resend account (
you@example.com) for transactional email
- Neon account for Postgres (one project per client, pooled connection)
- Google Search Console access
- Isolated browser MCP for visual comparison
Stack
- Next.js 16 (App Router) + React 19 + TypeScript
- Tailwind CSS 4 (inline theme via
@theme)
- next/image for optimized images
- Google Cloud Run for hosting (with HTTPS Load Balancer + Certificate Manager)
- PostHog for analytics (pageviews, CTA clicks, newsletter subscribes)
- Resend for transactional email (newsletter welcome, contact form, optional inbound webhook)
- Neon (
@neondatabase/serverless) for email + contact logs
Phase 1: Audit and Research
Phase 1 runs two tracks concurrently:
- Outward track (1a): understand the market the client sells into (competitors, search demand, industry developments, ICP).
- Inward track (1b-1e): audit what the client already has (SEO baseline, content crawl, assets, screenshots).
Both tracks feed 1f, which produces a single research-brief.md that every downstream phase (copy, hero, CTAs, FAQ, case studies) is required to consume. Research that does not make it into the brief is decoration.
1a. Market Research Fan-Out
Launch these agents in parallel. They have no dependencies on each other or on the inward track, so batch them in a single message.
Launch 4 agents in parallel:
- competitor-analysis: identify top 3-5 rivals by SERP + brand search.
Per rival, capture: positioning one-liner, pricing, primary CTAs,
hero copy, testimonial themes, messaging pillars, obvious gaps or
weaknesses.
- keyword-research + serp-analysis (single agent, both skills): head
terms, long-tail clusters, search intent (informational /
commercial / transactional), SERP feature mix (AI Overviews, PAA,
video, local pack), difficulty, monthly volume.
- deep-research-pro: industry developments in the last 90 days,
regulation, notable launches, funding, M&A, price moves, new
entrants, platform / distribution shifts. Cite sources.
- general-purpose (ICP pass, with WebFetch): 1-2 primary personas with
jobs-to-be-done, top 3 pains, top 3 gains, objections, triggers,
and the language they actually use (verbatim pulls from Reddit
threads, review sites, forum posts, NOT marketing copy).
Output: four raw reports in research/raw/ (competitors.md, keywords.md, industry.md, icp.md). Do not edit them down here, 1f does the compression.
Budget guardrail: if any single agent returns more than ~15k tokens, ask it to re-emit a tighter version capped at ~8k before moving on. Raw-output bloat is the main failure mode of this step.
1b. SEO Audit (if existing site)
Run parallel SEO agents to baseline the current site. This runs concurrently with 1a.
Launch 5 agents in parallel:
- seo-technical: crawlability, indexability, Core Web Vitals, mobile
- seo-content: E-E-A-T signals, readability, content depth
- seo-schema: existing structured data (JSON-LD, Microdata, RDFa)
- seo-performance: Lighthouse scores, LCP, CLS, TBT (desktop + mobile)
- seo-geo: AI crawler accessibility, llms.txt, citation readiness
Record all scores. These become the "before" baseline and the fix list for the new site.
1c. Crawl All Pages
Use an agent with WebFetch to discover and extract content from every page on the site:
- Fetch the homepage, extract all navigation and footer links
- Try common paths: /about, /services, /contact, /faq, /blog, /pricing, /testimonials, /privacy
- For each discovered page, extract:
- URL and page title
- All headings (h1 through h6) with hierarchy
- Full body text (quotes, testimonials, stats, descriptions)
- CTA text and link targets
- Form fields (if any)
- Navigation links (to discover more pages)
Output: Complete content inventory organized by page.
1c.1. Client Blog Inventory (warm-start seed for gsc-seo-page)
Skip this sub-phase only if the client has no blog, resources, guides, learn area, or article archive. When a post corpus exists, the upfront cost is one pass now versus a cold start for the SEO pipeline.
Goal: turn the client's existing blog into a seed list that Phase 9 upserts into the seo_keywords table on day one. The first 20 to 50 guide pages on our domain then target topics the client has already invested editorial work into, instead of cold DataForSEO discovery.
Hard boundary: the inventory captures topics, queries, and traffic signals, never body copy. Our /t/<slug> pages must be substantially longer, unbranded-intent rewrites of the same topic. Pasting their post body would trigger duplicate-content filtering and push our pages behind theirs in the SERP, defeating the entire reason we run a separate SEO domain (re-read line 15 of this file).
1c.1.a. Discover blog URLs
Extend the 1c crawl to follow paths matching /blog, /resources, /guides, /learn, /posts, /articles, /insights, /academy, /journal, plus any /[category]/[post-slug] pattern observed in the navigation. If the client has a sitemap, parse sitemap.xml and any post-sitemap.xml first; that is usually the complete list. Walk category and tag indexes to catch posts not in the sitemap.
1c.1.b. For each post, capture
| Field | Source |
|---|
url | canonical URL from <link rel="canonical"> or final URL after redirects |
title | page <title> (often "Post Title | Brand"); strip the brand suffix |
h1 | first <h1> text |
h2s | ordered list of <h2> strings (reveals sub-topics worth splitting into separate /t/<slug> pages) |
publish_date | <meta property="article:published_time">, visible byline date, or sitemap lastmod |
word_count | rough body word count (strip nav, sidebar, footer) |
primary_query | inferred unbranded category query, 2 to 5 words (see 1c.1.c) |
secondary_queries | up to 3 adjacent queries each large enough to be its own /t/<slug> page |
suggested_slug | kebab-case of primary_query |
gsc_impressions | trailing 90-day impressions if we have GSC access (1c.1.d), else null |
gsc_position | trailing 90-day average position if available, else null |
dedupe_note | "", or "near-dup of <other url>" when the client has two posts on the same query |
skip | false by default; "brand-only" if the post cannot be rewritten for unbranded intent (see 1c.1.c step 4) |
1c.1.c. Deriving primary_query
The client's title is almost never the right query for our domain. It is usually brand-forward ("How Cyrano Helps Apartment Owners Deter Package Theft") and we need unbranded category intent ("how to prevent apartment package theft"). For each post:
- Strip the brand name and product name from the title.
- Rewrite as the phrase a buyer who has never heard of the client would Google.
- Validate informally with Google Autocomplete; an autocompletion suggests the query has demand.
- If the rewrite forces you into a branded or tooling-specific phrase ("Cyrano integrations", "Fazm pricing"), mark the row
skip: brand-only and exclude it from the seed list. Brand queries belong on the brand site, not on our SEO domain.
1c.1.d. GSC join (optional but high-value)
If the client will grant Search Console access, have them add the email defined in ~/social-autoposter/.env as GSC_ADMIN_EMAIL (the address that owns the GCP project running this pipeline) as a Restricted user on their GSC property before this sub-phase runs. With access, fetch trailing 90-day query data per post URL through the GSC API (searchanalytics.query with dimensionFilterGroups on page) and join it onto the inventory. Priority tiers (used as a soft sort in the output .md, no special handling in the seed insert):
- Tier 1 (highest priority): posts ranking positions 5 to 25 on a non-branded query. Proven demand, good on-domain authority, reachable with a better page on our domain.
- Tier 2: posts ranking positions 26 to 100. Topical demand exists but the post under-delivers; our rewrite has plenty of room above it.
- Tier 3 (deprioritize): posts ranking positions 1 to 4 on their own domain. The client already owns that query; our version would cannibalize before it overtakes. Seed only if the secondary queries on that post are unowned.
When GSC access is not granted, all rows go in as Tier 2 by default; volume scoring happens later through DataForSEO via seo_keywords.volume.
1c.1.e. Output two artifacts