Autonomous content machine for UniqueStaysUSA. Combines SEO research, editorial writing, quality enforcement, and Payload CMS publishing into one pipeline. Runs as a PRD-driven loop — pick next article, execute 7 phases, commit, repeat.
When invoked without arguments, this skill runs automatically:
KEYWORD_RESEARCH_AND_CONTENT_CALENDAR.md)User overrides:
/journal-pipeline best cabins near Yellowstone/journal-pipeline --research-only/journal-pipeline --draft-onlyIf no override is given, assume autonomous mode and execute the full pipeline.
Each phase maps to a PRD story type. The pipeline reads and updates scripts/ralph/prd.json for sprint state persistence.
Research what to write before writing a single word.
Read these files:
KEYWORD_RESEARCH_AND_CONTENT_CALENDAR.md — content pillars, keyword clusters, monthly scheduledocs/uniquestays-gtm-strategy.md — distribution channels, KPIs, content goalsscripts/ralph/progress.txt — what's been done, learned patternsIf no topic specified — auto-select from calendar:
Parse the current month's table entries
For each journal post entry, check if a matching published post exists: bash
GET ${NEXT_PUBLIC_SERVER_URL}/api/blog-posts?where[status][equals]=published&depth=0&limit=50
Select the first uncompleted entry
Announce: "Starting autonomous creation of [topic] — next in calendar queue"
Proceed directly to Phase 2
Keyword research (always run):
Article type selection:
SignalTypeTemplateTopic names a specific city/regionDestination Dispatch3-5 stays, 1,400-2,000 wordsTopic names a stay categoryCurated Roundup8-12 stays, 1,800-2,500 wordsTopic names a season/monthSeasonal Guide5-8 stays, 1,500-2,200 wordsTopic names an activityActivity-Based Guide4-7 stays, 1,400-2,000 wordsTopic focuses on one propertyStay Spotlight1 stay, 1,000-1,500 words
Stay selection:
bash
# For destination dispatches
GET /api/stays?where[state][equals]={State}&where[rating][greater_than_equal]=4.7&limit=20&depth=1&sort=-rating
# For roundups
GET /api/stays?where[category][equals]={categoryId}&limit=50&depth=1&sort=-rating
# For activity-based (search by tags)
GET /api/stays?where[tags.tag][contains]={activity}&limit=20&depth=1
Select stays ensuring:
rating >= 4.7, reviewCount >= 30affiliateUrl (starts with https://)imageUrl or image relationship)Auto-proceed unless: No clear strategic gap exists — only then stop and propose alternatives.
Output: Sprint plan with target keyword, article type, selected stays, competitive angle. Update scripts/ralph/prd.json with 7 stories for this sprint.
Collect the raw material.
For each selected stay, collect from Payload (depth=1):
title, subtitle, location, state, regionprice, rating, reviewCount, platformdescription, tags (array of tag objects)affiliateUrl, imageUrlsleeps, bedroomsIdentify the "specific detail" for each stay: Find at least one concrete detail that could not apply to any other property. Sources:
tags array (e.g., "Wood-Burning Stove", "Stargazing Deck")description field (look for named landmarks, distances, species, history)External source research:
Verify:
affiliateUrlOutput: Stay data collection with specific details identified per stay. Mark RESEARCH as passed in prd.json.
Write the full editorial draft.
Invoke /elite-copywriter with:
docs/uniquestays-brand-guidelines.md)references/article-templates.mdWriting rules:
[EMBED: stay-slug] placeholders where each stay should appearreferences/quality-checklist.md)File location: content/drafts/{slug}.md
Frontmatter format:
yaml
---
title: ""
subtitle: ""
slug: ""
excerpt: ""
city: ""
state: ""
latitude: ""
longitude: ""
metaTitle: ""
metaDescription: ""
publishedAt: ""
status: "draft"
heroImage: "[description or source URL]"
linkedStays:
- stay-slug-1
- stay-slug-2
---
Output: First draft saved. Mark WRITE as passed in prd.json.
Optimize for search and AI citation. Read references/seo-requirements.md for the full checklist.
Keyword placement:
AI citation blocks:
Internal linking:
Query Payload for related posts: bash
GET /api/blog-posts?where[status][equals]=published&where[state][equals]={state}&depth=0&limit=10
Add 2+ contextual inline links to other journal posts
Link to relevant spoke pages where topic overlaps
Meta verification:
metaTitle under 60 chars, includes keyword, reads like editorialmetaDescription under 160 chars, includes keyword + specific detailslug is kebab-case, no dates in pathCannibalization check:
Output: SEO-optimized draft. Mark SEO as passed in prd.json.
Quality gate. Score against the rubric in references/quality-checklist.md.
Scoring: Rate each of the 8 criteria (voice match, specificity, feeling-first, banned words, SEO, embeds, practical value, cut test) on a 1-10 scale with weighted average.
ScoreAction8.0+AUTO-PROCEED to Phase 67.0-7.9One more edit pass targeting failing criteria, then re-scoreBelow 7.0STOP — identify what's missing, may need partial rewrite
Quality scans:
Save v2 to content/drafts/{slug}-v2.md.
Output: Quality score, specific improvements made. Mark REVIEW as passed in prd.json.
Publish directly to Payload CMS. No intermediate scripts needed.
Step 1: Resolve stay IDs
bash
# For each stay slug in linkedStays
GET /api/stays?where[slug][equals]={stay-slug}&depth=0&limit=1
# Collect: docs[0].id
Step 2: Upload hero image (if external URL, not already in media collection)
bash
# Fetch image, then upload to Payload media
# Use the two-step pattern from existing scripts
Step 3: Check for existing post
bash
GET /api/blog-posts?where[slug][equals]={slug}&depth=0&limit=1
totalDocs === 0 → POST /api/blog-poststotalDocs === 1 → PATCH /api/blog-posts/{id}Step 4: Construct Lexical JSON
Use these helper functions to build the content:
typescript
function text(content: string) {
return { type: 'text', format: 0, style: '', mode: 'normal', text: content, detail: 0, version: 1 }
}
function para(content: string) {
return {
type: 'paragraph', format: '', indent: 0, version: 1, direction: 'ltr',
textFormat: 0, textStyle: '',
children: [text(content)],
}
}
function h2(content: string) {
return {
type: 'heading', tag: 'h2', format: '', indent: 0, version: 1, direction: 'ltr',
children: [text(content)],
}
}
function embedBlock(stayId: number) {
return {
type: 'block', version: 2,
fields: { id: crypto.randomUUID(), blockType: 'stayEmbed', stay: stayId },
}
}
function hr() {
return { type: 'horizontalrule', version: 1 }
}
Step 5: Two-step update (follows the pattern in scripts/update-treehouse-article.ts)
First call — update heroImage + linkedStays + editorial fields:
bash
PATCH /api/blog-posts/{id}
{
"title": "...",
"subtitle": "...",
"excerpt": "...",
"heroImage": <media_id>,
"linkedStays": [<stay_id_1>, <stay_id_2>, ...],
"city": "...",
"state": "...",
"latitude": "...",
"longitude": "...",
"metaTitle": "...",
"metaDescription": "...",
"status": "published",
"publishedAt": "<ISO datetime>"
}
Second call — update content with Lexical JSON:
bash
PATCH /api/blog-posts/{id}
{
"content": { "root": { ... } }
}
Step 6: Verify
bash
# Check the API record
GET /api/blog-posts?where[slug][equals]={slug}&depth=1&limit=1
# Check the public page loads
GET https://uniquestaysusa.com/journal/{slug}
Step 7: Save final version to content/published/{slug}.md
Authentication: Authorization: users API-Key {key} — read from environment, never hardcode.
ISR revalidation: Automatic via Payload's afterChange hook in src/collections/BlogPosts.ts. No manual revalidation needed.
Output: Post live at /journal/{slug}. Mark PUBLISH as passed in prd.json.
Update tracking documents. Mandatory — never skip.
Update scripts/ralph/progress.txt:
Append a sprint summary:
markdown
### Sprint {N}: {Article Title}
**PLAN-{N}** ✓ — {keyword}, {article type}, {N} stays selected
**RESEARCH-{N}** ✓ — Stay data collected, {N} specific details identified
**WRITE-{N}** ✓ — First draft: content/drafts/{slug}.md
**SEO-{N}** ✓ — Keyword optimized, {N} internal links, FAQ added
**REVIEW-{N}** ✓ — Quality score: {X}/10, {N} edits
**PUBLISH-{N}** ✓ — content/published/{slug}.md
- Published at: /journal/{slug}
- Target keyword: {keyword}
- Word count: {N}
- Quality score: {X}/10
Record learned patterns in the progress file (what worked, what to do differently next sprint).
Verify sitemap inclusion:
bash
GET https://uniquestaysusa.com/sitemap.xml
# Check that /journal/{slug} appears
Update scripts/ralph/prd.json:
passes: truesprintNumbercurrentStory for next sprintGit commit:
bash
git add content/published/{slug}.md scripts/ralph/prd.json scripts/ralph/progress.txt
git commit -m "journal: publish \"{title}\" (sprint {N})"
Output: All tracking docs updated and committed. Mark SYNC as passed. Loop continues to next sprint.
The loop state lives in three files:
FilePurposescripts/ralph/prd.jsonSprint stories, pass/fail status, sprint numberscripts/ralph/progress.txtRunning log of completed work and learned patterns.claude/ralph-loop.local.mdRalph stop hook state (active, iteration count, completion promise)
.claude/ralph-loop.local.md — is a Ralph loop active?scripts/ralph/prd.json — what's the current sprint and story?scripts/ralph/progress.txt — what patterns have been learned?passes: falseprd.json with passes: true/ralph-cancel or removes active: true from loop state)The existing Ralph stop hook at .claude/ralph-loop.local.md controls loop persistence across context windows. The skill reads this file at the start of each iteration. When a context window closes, the stop hook re-feeds the journal-pipeline prompt to continue where it left off.
The calendar lives at KEYWORD_RESEARCH_AND_CONTENT_CALENDAR.md. Parse the monthly tables:
ColumnUseWeekSchedulingContent TypeJournal Post vs Lead Magnet vs ProgrammaticTitle/TopicThe article topicTarget KeywordsPrimary + secondary keywordsGoalThe strategic purpose
Calendar signalArticle typeTopic mentions a city/state/regionDestination DispatchTopic mentions a stay category (treehouses, cabins, domes)Curated RoundupTopic mentions a season or monthSeasonal GuideTopic mentions an activity (stargazing, fishing, hiking)Activity-Based GuideTopic mentions a specific property by nameStay Spotlight
elite-copywriter for polish passes)