getlifeprotection.com (GLP blog + content pipeline)
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;
CNAMEpoints the custom domain. - Permalink pattern is
/blog/:slug/(set in_config.yml). A post’s URL comes from its front-matterslug:, which can differ from the filename. future: falsein_config.ymlis 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 inscripts/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.mjsreads 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/byscripts/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.mdlists 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 holdsblog-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/.
- Thursday generator (GitHub Actions,
generate-week.yml). Runsscripts/generate-post.mjs --weekat 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 areview/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-syncedGLP 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. - 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. - Weekly review + image + publish (human-gated). Elijah reviews both sources
(the
glp-weekly-reviewskill), attaches hero images (theattach-imagesskill), then runs the publish path: move winners to_drafts/, run the REALsync-drafts.mjsgate, 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. - Daily deploy cron (GitHub Actions,
pages.yml). Builds and deploys at 13:11 UTC daily (~06:11 Pacific). Because offuture: 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. - Monday SEO audit (GitHub Actions,
seo-audit.yml). Runs 13:30 UTC Mondays, commits a report to_seo-reports/. - Thursday mirror job (launchd,
com.elijahmang.glp-cowork-pull). Runs 11:00 Thursday (Mac local time), before the 2 PM Cowork run. Itgit pull --ff-only origin mainon~/getlifeprotection, thenrsync -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/.
The internal-link rule (07-18 hard-won; the most important rule here)
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-matterslug:(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 slughow-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 underfuture: 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 todate: <= 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.jsonas 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)
- Review + select with the
glp-weekly-reviewskill: 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. - Attach images with the
attach-imagesskill: it finds a warm, authentic, DIVERSEimages.unsplash.comhero per winner (natural mix, not a diversity quota; never default to all-white stock), shows Elijah clickable candidates, and writes only theimage:line on approval. Never a watermarkedplus.unsplash.comURL. - 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 missingtitle/slug/date/description, with a malformed date or slug, with noimage:, or with aplus.unsplash.comimage; it WARNS (does not block) on a duplicate hero image. It does not commit or push; it printsgit statusfor your review. That review is the human gate. Run--dry-runfirst to preview. - Future-date Mon–Sun so each post publishes on its own day under
future: false. - Rebuild the content index (
build-content-index.mjs, whichsync-draftsalso does after a move) and commit it; the index should never drift stale. - Commit + push, then the daily
pages.ymlcron 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.jsonfor what is live caused the 07-18 dead-link grind. It lags_posts/. Verify links against_posts/(or_posts-live/for Cowork), filtered todate: <= today, resolving by front-matterslug:. - 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/andscripts/are excluded from the Jekyll build (the optional- front-matter plugin would otherwise render every.mdand parse Liquid inside, which broke the 2026-07-03 deploy). Keep internal.mdout 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).