getlifeprotection.com (GLP blog + content pipeline)

What this is

The consumer-facing marketing site for Get Life Protection (getlifeprotection.com): a Jekyll blog that educates everyday people shopping for life insurance and routes them to a quote or a policy review. This repo is the site’s source. It is NOT the CRM (that is ~/agent-dashboard) and NOT the personal-advisor brand (no leverage/IUL-for-investors/generational-wealth framing). Write for a regular person who searched a question like “life insurance with COPD.”

The site is live and content publishes on a drip with no manual step per post, so a mistake in a draft (a dead internal link, an em dash, a priced dollar figure) ships on its own date unless it was caught before it entered the queue. That is why the rules below are strict.

Stack and how it ships

  • Jekyll (kramdown, GitHub Pages gem), deployed via GitHub Actions (not “deploy from branch”). Hosted on GitHub Pages; CNAME points the custom domain.
  • Permalink pattern is /blog/:slug/ (set in _config.yml). A post’s URL comes from its front-matter slug:, which can differ from the filename.
  • future: false in _config.yml is load-bearing: a post dated ahead of today stays hidden until a build runs on or after its date. That is what makes the drip work without a manual publish per post. Do not flip it.
  • Local build/preview: bundle exec jekyll build / bundle exec jekyll serve. Node pipeline scripts live in scripts/ and run on Node (nvm; the pinned version path is used by the skills, e.g. ~/.nvm/versions/node/v22.22.2/bin/node).

Where things live

  • _posts/: published posts (YYYY-MM-DD-slug.md). This is the source of truth for what is or will be live. Future-dated files sit here committed ahead of time and go live on their date.
  • _drafts/: the in-repo staging folder. sync-drafts.mjs reads here and moves publish-ready files into _posts/. The Thursday generator also commits its clean drafts here (drafts-only, never _posts/).
  • _data/content-index.json: a generated index of published posts (title, slug, url, date, image, sourceFile), rebuilt from _posts/ by scripts/build-content-index.mjs. Convenience, not the source of truth for what is live (see the internal-link rule; it can lag _posts/).
  • _review/: the generator’s review-branch home: _review/PR_BODY.md lists which drafts were clean / held for compliance / dropped on validation; _review/held/ holds the compliance-held copies.
  • _seo-reports/: the Monday SEO audit output.
  • content/glp-voice-and-rules.md: the canonical voice, compliance, and front-matter spec. The generator reads it. Treat it as authoritative for voice.
  • scripts/: the pipeline: generate-post.mjs (weekly generator), sync-drafts.mjs (the publish gate), build-content-index.mjs, review-pr.mjs, notify-discord.mjs, seo-audit.mjs, plus versioned skill reference copies (*.skill.md). scripts/ is excluded from the Jekyll build.
  • GLP Content/: a gitignored, Drive-synced working tree used by the Cowork Mac tasks. It holds blog-drafts/ (the Cowork writer’s output), _posts-live/ (the mirror of published posts, see below), plus research, newsletters, and reviews. It exists only in the MAIN working copy (~/getlifeprotection), never in a worktree, and never in the repo’s git history.

How the content pipeline works end to end

Two independent draft sources feed one human-gated publish path. Nothing auto-publishes; the drip only moves posts that a human already placed in _posts/.

  1. Thursday generator (GitHub Actions, generate-week.yml). Runs scripts/generate-post.mjs --week at 14:00 UTC Thursdays, targeting the UPCOMING Mon–Sun. It is deliberately disarmed: it writes and commits _drafts/ ONLY (two layers enforce this), never _posts/. It pushes a review/week-of-<monday> branch and opens ONE review PR, then notifies Discord with the digest and any compliance escalation. On GitHub’s servers the Drive-synced GLP Content/ research tree is absent, so it falls back to the in-repo topic bank. Dedup is vs _posts/ only; drafts never count as covered.
  2. Cowork blog-writer (Task 2, scheduled Mac). Runs ~2 PM and drafts 7 posts (Mon–Sun) into GLP Content/blog-drafts/. Its live-link source of truth is _posts-live/ (see the internal-link rule). Its SKILL lives at ~/Documents/Claude/Scheduled/task-2-blog-writer/SKILL.md.
  3. Weekly review + image + publish (human-gated). Elijah reviews both sources (the glp-weekly-review skill), attaches hero images (the attach-images skill), then runs the publish path: move winners to _drafts/, run the REAL sync-drafts.mjs gate, rebuild the content index, future-date Mon–Sun, and commit. glp-weekly-publish (the one-command wrapper) is coming in week 2; for now the tail is run by hand.
  4. Daily deploy cron (GitHub Actions, pages.yml). Builds and deploys at 13:11 UTC daily (~06:11 Pacific). Because of future: false, this is the build that makes each future-dated post go live on its own date. It ignores pushes that only touch _seo-reports/, _data/seo-snapshots.json, or _drafts/ so those commits do not trigger a pointless rebuild.
  5. Monday SEO audit (GitHub Actions, seo-audit.yml). Runs 13:30 UTC Mondays, commits a report to _seo-reports/.
  6. Thursday mirror job (launchd, com.elijahmang.glp-cowork-pull). Runs 11:00 Thursday (Mac local time), before the 2 PM Cowork run. It git pull --ff-only origin main on ~/getlifeprotection, then rsync -a --delete _posts/ → "GLP Content/_posts-live/" so the Cowork task reads the EXACT current published set. It fails loud in its log if either step fails. This job is the patch that fixed the Cowork link bug: Cowork could not see git/live truth, so the mirror hands it a fresh copy of _posts/.

A blog link is safe ONLY if its target is a post that is actually LIVE right now. A 404 from a dead internal link ships silently on the drip. Resolve links this way:

  • Live = published on origin/main _posts, dated on or before today. Resolve a link by the target post’s front-matter slug: (the permalink), which can differ from the filename. Never build a slug from a title, and never guess one (e.g. “How Much Life Insurance Do I Actually Need?” has slug how-much-life-insurance-do-i-need, no “actually”).
  • Filter to date: <= today. Future-dated posts are committed in _posts/ (and mirrored into _posts-live/) but are NOT live yet under future: false. A file being in the folder does NOT mean it is live; linking a future-dated post is a dead link until its date arrives.
  • For the Cowork writer, the source of truth is _posts-live/ (the Thursday mirror of _posts/), filtered to date: <= today. Report the slug-count health line every run (“Loaded N live slugs; skipped M future-dated”); a count of 0 or far below expected means the pull/mirror failed and blog links are suspect. If _posts-live/ is missing or empty, fall back to product pages / /quote/ / /policy-review/ only and say so, never guess.
  • Never link against blog-drafts/, sibling same-week drafts, or a post’s title. A draft that loses its publish slot to the generator never goes live and 404s (three Cowork targets lost their slots and died this way).
  • Do NOT trust _data/content-index.json as the source of truth for what is live. It is a convenience index rebuilt from _posts/ and it can lag. Trusting it stale caused the 07-18 dead-link grind. Verify against _posts/ (or _posts-live/ for Cowork), not the index.

Every blog link must resolve to a real live post. /quote/ and /policy-review/ and the product pages (/life-insurance/term|whole-life|final-expense|iul/) always exist and are safe fallbacks; blog-to-blog links are the risky ones.

Voice and content rules (canonical in content/glp-voice-and-rules.md)

Read content/glp-voice-and-rules.md for the full spec. The rules a session must never get wrong:

  • Plain agent/human voice. Write like a knowledgeable friend explaining something over coffee, not an essayist being clever. Kill the AI tells: the “quiet = wisdom” tic, knowing-aside section openers (“Here is the part that gets glossed over”), the “not X, it is Y” seesaw as a rhythm, and several sections or posts opening with the same move.
  • No em dashes, anywhere. An em dash is an AI-writing signal. Use commas, periods, or restructure. (The weekly-review script counts them; any count > 0 is a fail to fix or drop.)
  • “Guarantee” is allowed only as a conditioned mechanism, never an outcome promise. The test: does the sentence promise the reader a RESULT (approval, rates, “will have coverage”) or DESCRIBE how the contract works with its conditions stated (policy in force, premiums paid, age/eligibility, contestability)? The first is prohibited; the second is fine. “Guaranteed issue” / “guaranteed acceptance within the eligible age range” as product terms are OK; “guaranteed coverage” with no condition is not.
  • No specific premium/price dollar figures (a coverage amount like “$1M of term” is fine; a monthly/annual price is not). No named rate classes (“Preferred Plus”). No hashtags. Soft CTAs only (gentle nudge to /quote/, /policy-review/, the phone, or a related post; never “act now” or “DM me”). “Most people don’t realize” at most once across a whole week.
  • Carrier references use qualifying language (“carriers known for,” “many carriers specialize in”), never “this carrier will approve you.” Carrier facts are never taken from model memory; if a draft asserts a carrier rule you cannot verify against ~/carrier-knowledge/, flag it rather than pass it.
  • Structure: reader-first opening (lead with the situation/question and answer the core question within ~200 words; no scene, metaphor, or literary cold-open), 800–1500 words, at least three H2 sections, primary keyword in the title and at least one H2, at least three internal links (≥1 product page AND ≥2 distinct already-live blog posts).

Front-matter shape (match live posts exactly)

layout: post
title: "<under 60 chars; includes the primary keyword>"
slug: <kebab-case; matches the intended URL>
description: "<140–160 chars, plain English; includes the primary keyword>"
category: "<e.g. Coverage Planning | Health and Underwriting | Final Expense>"
primary_keyword: "<main search phrase; also in the title and ≥1 H2>"
date: YYYY-MM-DD        # unquoted; THIS is the field the site builds from
image: "<a free images.unsplash.com URL>"   # never plus.unsplash.com

Filename is YYYY-MM-DD-slug.md and the date in the filename must match date:. Body starts immediately with the opening paragraph, NO leading # H1 (the layout renders the title). Use description:, not meta_description:. Generated drafts carry image_search: (a hint) instead of image:; the real hero is added at the image step before sync.

The publish flow (review → select → image → sync gate → future-date → index → drip)

  1. Review + select with the glp-weekly-review skill: it gathers both draft sources, scores each against the criteria above, and proposes a Mon–Sun lineup with a pinned red-flag block. Elijah approves or overrides. Review only, never publishes.
  2. Attach images with the attach-images skill: it finds a warm, authentic, DIVERSE images.unsplash.com hero per winner (natural mix, not a diversity quota; never default to all-white stock), shows Elijah clickable candidates, and writes only the image: line on approval. Never a watermarked plus.unsplash.com URL.
  3. Sync gate: run the REAL scripts/sync-drafts.mjs (never bypass it). It validates and normalizes front matter, then moves publish-ready files from _drafts/ into _posts/. It refuses (non-zero exit) any draft missing title / slug / date / description, with a malformed date or slug, with no image:, or with a plus.unsplash.com image; it WARNS (does not block) on a duplicate hero image. It does not commit or push; it prints git status for your review. That review is the human gate. Run --dry-run first to preview.
  4. Future-date Mon–Sun so each post publishes on its own day under future: false.
  5. Rebuild the content index (build-content-index.mjs, which sync-drafts also does after a move) and commit it; the index should never drift stale.
  6. Commit + push, then the daily pages.yml cron drips each post live on its date. Confirm the Actions deploy went green.

glp-weekly-publish (a one-command wrapper over review → image → sync tail, with a gated Discord report) is the week-2 build; it never arms auto-publish.

Hard-won gotchas (each cost a real incident or a long grind; don’t relearn them)

  • Trusting _data/content-index.json for what is live caused the 07-18 dead-link grind. It lags _posts/. Verify links against _posts/ (or _posts-live/ for Cowork), filtered to date: <= today, resolving by front-matter slug:.
  • A draft in a folder is not a live post. Future-dated posts sit in _posts/ and _posts-live/ before their date; sibling same-week drafts may lose their slot and never publish. Both 404 if linked as if live.
  • GLP Content/ is gitignored and Drive-synced, main-repo-only. Edits there are real and immediate but exist only in ~/getlifeprotection, never in a worktree and never in git history. The generator’s servers never see it.
  • The generator is drafts-only by design. It writes _drafts/ and opens a review PR; it never touches _posts/. Do not “fix” it to publish; the human gate is intentional.
  • docs/ and scripts/ are excluded from the Jekyll build (the optional- front-matter plugin would otherwise render every .md and parse Liquid inside, which broke the 2026-07-03 deploy). Keep internal .md out of the build path.
  • Skill reference copies live at scripts/<name>.skill.md (versioned), the live skill at ~/.claude/skills/<name>/SKILL.md. Keep them in sync when a skill changes (byte-identical apart from the header comment).