- 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:
1. Fetch the homepage, extract all navigation and footer links
2. Try common paths: /about, /services, /contact, /faq, /blog, /pricing, /testimonials, /privacy
3. 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:
1. Strip the brand name and product name from the title.
2. Rewrite as the phrase a buyer who has never heard of the client would Google.
3. Validate informally with Google Autocomplete; an autocompletion suggests the query has demand.
4. 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
View on GitHub