Peak Interval Content Pipeline

Modes, selected by the argument: research (weekly), publish (daily). No argument = publish. Blog only — Peak Interval's social publishing runs on its own separate pipeline, so this one never touches Planoly or carousels.

The site is Eleventy, not Next.js: posts are markdown files in blog/posts/<slug>.md with YAML front matter, built with npm run build, deployed to Vercel from main. Live URLs are https://peakintervalapp.com/blog/posts/<slug>/ — note the /posts/ segment, which the IndexNow and index-check steps both need.

The queue lives at docs/marketing/content-queue.json:

{ "themes": [ { "slug": "...", "theme": "...", "hook": "...", "query": "...",
    "evidence": "one line citing the Reddit/creator signal", "pain_quotes": ["..."],
    "category": "one of the existing categories, exactly",
    "distinctFrom": "closest existing slug — and the different question this answers",
    "searchEvidence": "striking-distance query with impressions/position, or a keyword with its monthly volume",
    "imagePrompt": "what the featured image should show" } ],
  "retired": [ { "slug": "...", "reason": "duplicate of <slug>", "date": "2026-08-23" } ] }

Publish runs remove the FIRST theme after shipping its post. Research runs append. Commit queue changes with the rest of the run.

retired is the memory of topics deliberately rejected. Never re-propose a retired slug or its question — read the list every research run.

Categories are a closed set. Use one of these exactly, or the post falls out of the category pages: Workout Guides, Specialized HIIT Training, HIIT Fundamentals, Fitness + Nutrition, Advanced HIIT Concepts, App Features, HIIT Success Stories, Buying Guides, Comparisons.

Stay in the app lane — this is the whole strategy

On 2026-08-23, commit 5e9fc2b deleted 89 of the 117 posts. The reason is the single most important thing to understand before choosing any topic. Search Console showed only 31 of 132 URLs indexed, split by intent like this:

Lane Posts Indexed
App lane — timers, interval formats, named competitors, how-to-build-X 24 71%
Generic fitness editorial — sleep, protein, carb cycling, mental health 93 4%

Google predicts a page's value from the rest of the domain before it spends a crawl, so the generic library was not merely useless: at 79% of the site's URLs returning almost nothing, it was capping crawl demand for the pages that do work. All 89 pruned slugs are in the queue's retired list and the gate scores against them.

So: do not write generic fitness editorial. A post about sleep, macros, motivation, or the physiology of EPOC is off-strategy here no matter how strong the Reddit signal behind it. Good topics for this blog answer a question someone asks while trying to run intervals — which timer, which format, how to set up a specific workout, how the Watch flow works, how Peak Interval compares to a named competitor, how to build a specific protocol.

That constraint is what makes this pipeline different from QuestSpark's, where any parenting pain point is fair game.

Never publish the same topic twice

28 posts remain, and they are commercially dense: half a dozen legitimately resemble each other because they are all about choosing an interval timer. The gate is not optional:

python3 Scripts/marketing/topic_overlap.py index      # every published + queued topic
python3 Scripts/marketing/topic_overlap.py audit      # queue vs published, closest neighbours
python3 Scripts/marketing/topic_overlap.py calibrate  # live score distribution + worst existing pairs
python3 Scripts/marketing/topic_overlap.py check "<slug>" "<theme> <hook> <query>"

check refuses outright in three cases: the slug is already published, the slug is in retired, or the closest neighbour scores 0.50+. At 0.22+ you must open the closest neighbour in blog/posts/ and read it, then write one sentence in distinctFrom naming that slug and the different question your post answers. If you cannot write that sentence honestly, drop the topic.

Thresholds were calibrated against the post-prune corpus on 2026-08-23 (28 posts, median pair 0.053, p99 0.486, max 0.619), not inherited. They sit higher than QuestSpark's precisely because this corpus is dense. Re-run calibrate once the corpus passes ~45 posts.

Two deliberate false positives, both commercial series: peak-interval-vs-* posts score 0.35-0.49 against each other, and best-<format>-timer posts score 0.33-0.62. If the closest neighbour is a sibling in one of those series and the competitor or format genuinely differs, ship it. One real duplicate survived the prune — best-emom-timer and best-tabata-timer at 0.62 — and consolidating that pair is worth more than a new post.

A fresh angle, a newer study, or a different exercise list is not a different topic. A different question someone typed into Google is.

Let search results steer the queue

Peak Interval's posts were written from an idea list with no feedback from search, which is exactly how 89 of them ended up deleted. Both properties are wired into the globally-registered app-analytics MCP server, so that loop can close:

Four things about this data that will mislead you if you forget them:

Mode: research (weekly — Monday)

  1. Run topic_overlap.py index and read the retired list. New themes must duplicate no published post, no queued theme, and no retired slug. Also skim blog-post-ideas.md for anything still un-DONE worth reviving.
  2. Research demand signals, scoped to the subreddits in docs/marketing/reddit-communities.md (broad fitness keyword searches come back full of supplement spam and gym selfies):
  3. Read what search already says about our own posts. Two queries, both dataState: "final", both with siteUrl: "sc-domain:peakintervalapp.com":
  4. Keyword research, for volume the pain signals never surface.
    python3 Scripts/marketing/keyword_volume.py ideas --seeds "hiit" "interval timer" --min-volume 100
    python3 Scripts/marketing/keyword_volume.py ideas --urls https://peakintervalapp.com/blog/posts/<a-strong-post>/ --min-volume 100
    
    Vary seeds run to run — short head terms only (hiit, tabata, interval timer, cardio, fat loss, treadmill, kettlebell). At least 2 themes must come from this list, with the keyword and volume in searchEvidence. Judge intent: high volume with HIGH competition is usually commercial intent that ad budgets own, and fitness seeds drag in supplement and equipment queries a blog post cannot serve. volume mode must never reject a theme — it returns 0 for phrases that plainly have traffic.
  5. Draft 7 themes, each with: kebab-case slug matching search phrasing, theme, one-line hook, target query, one-line evidence, 2-3 paraphrased pain_quotes (never verbatim user text), a category from the closed set above, and an imagePrompt.
  6. Gate each one before it enters the queue. Run check on every candidate and follow the rules above. Candidates must be distinct from each other, not just from what exists. No more than 2 themes per category in a batch of 7 — the corpus is already 23 posts deep in Workout Guides.
  7. Append the survivors, then run audit and confirm no BLOCK rows.
  8. Commit + push main. Report the themes in one short paragraph, say which candidates you rejected and what they collided with, and give the search summary (see Report).

Mode: publish (daily — blog only)

Take the FIRST theme off the queue. If the queue is empty, do a 10-minute mini-research (step 2 above) to pick one theme, then proceed — and note the empty queue in the report.

Gate it before writing a word, since a theme queued days ago can be overtaken by a post published since:

python3 Scripts/marketing/topic_overlap.py check "<slug>" "<theme> <hook> <query>"

If you retire every remaining theme, publish nothing and say so in the report and the iMessage. A skipped day is cheaper than a duplicate post.

Write the post

Create blog/posts/<slug>.md. Front matter must carry every field or the post breaks the index and category pages:

---
title: "Title In Title Case"
description: "~155 chars, the search-result snippet. Mentions Peak Interval naturally where it fits."
date: "YYYY-MM-DD"
featured_image: "/assets/blog/<filename>.png"
image_alt: "Describes what the generated image actually shows"
layout: "post.liquid"
tags: "posts"
category: "<one of the closed set>"
---

Generate the cover first, so featured_image points at something real:

python3 Scripts/marketing/generate_blog_image.py "<imagePrompt from the queue>" "<slug>.png"

It prints the exact featured_image: line to paste. If it reports no API key, publish nothing and say so in the report and the iMessage — a post without a cover breaks every existing layout, and silently shipping one is worse than skipping a day.

When writing the imagePrompt, never ask for a readable phone screen, watch face or clock. The generator forbids text, but a screen in frame overrides that and the model renders garbled lettering, which is the most obvious AI tell a cover can carry. Describe the moment around the device instead.

Look at the generated image (Read the file) before committing it. Check it matches the post, has no text artefacts, and shows plausible anatomy — a bad cover is worse than a generic one.

Body style, matched to the surviving posts: open with two short paragraphs that name the reader's actual situation, then 3-5 ## sections of 2-3 short paragraphs each, second person, concrete numbers over adjectives. Mention Peak Interval's mechanics (interval setup, Apple Watch flow, rest/prep phases, audio cues) only where they genuinely answer the question — the existing posts that rank are the ones that help first and sell second. Close with a short practical takeaway, not a hard pitch.

Verify npm run build 2>&1 | tail -5 succeeds and the post appears in _site. Commit + push main (house workflow, no PRs). Remove the theme from the queue and commit.

Tell Bing immediately (after the push)

ChatGPT's search grounds on Bing's index, so a post Bing has not crawled is invisible to that assistant however well it ranks on Google.

python3 Scripts/marketing/indexnow_ping.py https://peakintervalapp.com/blog/posts/<slug>/

The script waits for the Vercel deploy to serve the URL before submitting — pointing a crawler at a 404 is worse than being found a day later by the sitemap — so expect it to take a couple of minutes. HTTP 200 or 202 is success. If it reports the key file unreachable, the deploy has not landed or bc78c3ae8d93a7d9bcb54ddaa9c837bd.txt fell out of the Eleventy passthrough in .eleventy.js; say so in the report rather than retrying blindly.

Index check (after the push, before the notify)

Cheap regression test on the one thing that would silently kill every post at once. Do not inspect the post you just shipped; Google has not seen it yet. Inspect the post published 3 days ago:

inspect_search_console_url  https://peakintervalapp.com/blog/posts/<slug-from-3-days-ago>/
  with siteUrl: "sc-domain:peakintervalapp.com"

indexStatusResult.verdict should be PASS. Anything else — NEUTRAL, "Discovered - currently not indexed", "Crawled - currently not indexed" — means new posts are not reaching the index. Say so in the report and the iMessage. One post lagging is normal variance; two consecutive days failing is a real breakage worth stopping for.

Notify (after the push succeeds)

All iMessage sends go through the QSNotify shim app, which permanently holds the Automation + Full Disk Access grants (direct osascript/imsg calls break every time a claude CLI or homebrew update replaces the calling binary — do not use them):

printf 'send|New Peak Interval blog post is live: https://peakintervalapp.com/blog/posts/<slug>/' > /Users/djordjejankovicmacmini/QuestSpark/logs/qsnotify.cmd
open -W -a /Users/djordjejankovicmacmini/Applications/QSNotify.app
tail -1 /Users/djordjejankovicmacmini/QuestSpark/logs/qsnotify.log

The shim lives in the QuestSpark repo and is shared by both pipelines — that path is correct, not a copy-paste error. The tail line is the delivery verification: success looks like ... send ok | <chat.db timestamp> sent=1 err=0. If it reports send FAILED or chat.db unreadable, say so in the run report — do not fall back to raw osascript or imsg.

If any phase of any mode failed, still send an iMessage saying which phase failed and what state was left, so a silent broken run never goes unnoticed.

Report

Short plain-prose summary: theme + evidence line, blog slug, the closest existing post and why this one is different, anything retired as a duplicate, and queue depth remaining. If any phase failed, say which and what state was left.

Publish runs add one line for the index check: the post inspected and its verdict. Research runs add a short search-performance paragraph — impressions and clicks for the last 28 days against the 28 before, which striking-distance queries were found and which themes were aimed at them, which existing posts are actually earning impressions, and which themes came from keyword research with the volume behind them. Say plainly how the 7 split across the three sources (pain signals, striking distance, keyword volume). If the Search Console sample was too thin to steer on, say that instead of inventing a trend.