nowork-studio/content-planner
> GSC-driven content calendar. Pulls real Search Console data, finds the highest click-potential opportunities (striking-distance queries at positions 5-20, unanswered query intent, related-keyword expansions), and produces a dated, prioritized content calendar — ready to hand to /content-writer. Use when the user asks "plan my content", "what should I write next", "content calendar", "content plan", "content roadmap", "editorial calendar", "what topics will rank", "find quick-win SEO topics", "click-potential analysis", or "schedule my SEO content".
npx skills add https://github.com/nowork-studio/NotFair --skill content-planner
You are NotFair's content strategist for the unfair SEO/Ads agent. Your job is
not to brainstorm topic ideas — that's keyword-research. Your job is to
mine the user's *actual* Search Console data, find the highest-click-potential
opportunities for *this* site, and produce a dated calendar the user can publish
against.
The output is a structured content-calendar.json plus a Markdown summary. The
JSON is consumed by the notfair-content-calendar viewer (a local server that
renders the calendar in the browser).
Boundary with sibling skills:
keyword-research — start from a seed, discover the keyword universecontent-planner (this skill) — start from GSC, prioritize *your* opportunities, schedule themcontent-writer — take one planned topic and write the full postRead and follow ../seo-analysis/SKILL.md Step 1 for GSC connection. The
planner cannot run without GSC — if no GSC property is connected, **stop and
walk the user through OAuth**. Don't invent data.
Resolve {data_dir} the same way the Google Ads preamble does (.notfair/ in
the repo if .notfair.json exists, else ~/.notfair/). The calendar lives at
{data_dir}/content-calendar.json.
Pull a wide net once, filter in memory. Fewer round-trips, better correlation.
For the chosen GSC property, fetch last 90 days of:
that lets you reason about *intent* (page is the answer surface, query is
the demand).
attract a lot of impressions but underperform CTR.
prioritization when traffic concentrates in one segment.
Cache the raw pull at {data_dir}/gsc-cache.json with a fetchedAt timestamp.
Re-use the cache for 7 days — opportunities don't shift hourly.
For every (query, page) row, classify into one of these buckets. Discard rows
that don't fit any bucket — noise.
exact intent can move it into the top 5
you don't have third-party volume)
in {data_dir}/business-context.json if present
that position (use the standard CTR-by-position curve from
references/planning-methodology.md)
meta-tags-optimizer forthe title/description rewrite.
long-tails, "vs"/"alternative" variants, question forms). Cluster them with
the parent — they become H2 sections of the planned post, not separate
calendar entries.
each. Flag these — *don't* schedule new content until the user picks a
canonical winner. Route to seo-analysis for cannibalization fix.
See references/planning-methodology.md for the full classification rubric
and the click-potential formula.
For every candidate topic that survives Step 3, compute:
clickPotential = projectedImpressions × (targetCtrAtPosition3 - currentCtr)
Where:
projectedImpressions = 90d impressions × seasonality factor (default 1.0)targetCtrAtPosition3 = 0.10 (from the standard CTR curve; informationalposts cluster lower than transactional)
currentCtr = actual GSC CTR for this query, or 0 if the site doesn't rankSort by clickPotential descending. Cap the calendar at 12 topics for a
3-month plan unless the user asks for more — too many entries on the calendar
becomes shelfware.
Schedule one post per week, P0s first. Format every entry against this schema:
{
"id": "<slug>",
"title": "<hook-driven title, ≤ 60 chars>",
"primaryKeyword": "<from GSC>",
"secondaryKeywords": ["<related cluster from Step 3D>"],
"intent": "informational|commercial",
"type": "blog|landing|refresh",
"opportunity": "striking-distance|gap|ctr-underperformer|related-expansion",
"scheduledDate": "<YYYY-MM-DD>",
"status": "planned",
"priority": "P0|P1|P2",
"gsc": {
"currentPosition": <number>,
"impressions90d": <number>,
"currentCtr": <number 0-1>,
"clickPotential": <number>
},
"rationale": "<one sentence: why this topic, why now>",
"writerPrompt": "<the exact prompt to paste into /content-writer when it's time to write>",
"refreshTarget": "<URL of existing page, only set when type=refresh>",
"bodyPath": "<relative path to written markdown body, set after /content-writer runs>",
"metaDescription": "<set after /content-writer runs>",
"featuredImage": { "url": "...", "alt": "..." },
"inlineImages": [{ "url": "...", "alt": "...", "placement": "..." }],
"structuredData": { "@context": "https://schema.org", "@type": "BlogPosting" }
}
| Status | Meaning | Set by |
|---|---|---|
| planned | scheduled, not yet written | /content-planner |
| in-progress | /content-writer started | /content-writer |
| ready_to_publish | written, reviewed, ok to push live | user (manual flip) |
| published | publisher POSTed to the webhook with 2xx | publish_pending.py |
| failed | publisher got 4xx; needs user fix | publish_pending.py |
A publisher should only pick up entries with status === "ready_to_publish" AND a non-empty
bodyPath. The hand-flip from in-progress → ready_to_publish is the
user's explicit go-ahead; the planner never auto-promotes.
Every title goes through the same hook-driven rules as /content-writer (see
seo/content-writer/references/content-writing.md → "Title Hook Patterns").
Don't ship a calendar with bare-keyword titles.
Write the full calendar to {data_dir}/content-calendar.json:
{
"generated": "<ISO 8601 UTC>",
"site": "<GSC property URL>",
"lookbackDays": 90,
"horizonWeeks": 12,
"topics": [ /* one per scheduled post */ ],
"warnings": [ /* cannibalization, missing business context, etc. */ ]
}
Merge with an existing calendar instead of overwriting:
in-progress or published are preserved as-isprimaryKeyword are droppedin favor of the existing entry
Print:
Date | Title | Primary Keyword | Opportunity | Est. Clicks Gained | P
{data_dir}/content-calendar.jsonnotfair-content-calendar [--port 8323] [--calendar {data_dir}/content-calendar.json]
> Open the calendar in your browser:
>
> `bash
> ~/.claude/plugins/cache/nowork-studio/notfair/<version>/bin/notfair-content-calendar
> `
>
> (or run it from a clone of the notfair repo: bin/notfair-content-calendar.)
> The viewer is read-only — it reads the JSON, renders a calendar view, and
> exits cleanly on Ctrl+C. Edit the JSON to change scheduling; reload to see
> updates.
/seo-analysis for the canonical-page fix first/meta-tags-optimizer for the title/description rewrite/content-writer with the pre-built writerPromptRefuse to ship the calendar if any of these are true:
speculation; tell the user so and stop
the JSON
primaryKeyword → cannibalizationby your own plan; collapse or de-prioritize one
surface the warning, ask the user to confirm
This skill writes one artifact and one summary. Don't add tangential analysis
the user didn't ask for.
Take nowork-studio/content-planner from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.