AI Sysadmin Content Pipeline
Automate LinkedIn posts in your voice using Claude Code and Python.
Introduction
30 Second Summary
Creating content consistently is one of the biggest challenges for anyone building a personal brand online. What if you could turn your audience research into polished, voice-matched posts without spending hours writing each one?
In this project, you will build a complete content pipeline that reads pain point data from your audience research, generates LinkedIn posts in your authentic voice using Claude Code, auto-publishes them with a lead magnet link in the first comment, and logs everything for performance tracking.
What You'll Build
A Python-based content automation pipeline that connects audience research to AI-powered content generation, LinkedIn auto-posting with first-comment CTAs, and engagement tracking.
By the end of this project, you'll have:
- A config-driven pipeline that generates LinkedIn posts in your voice using Claude Code's CLI mode, with dry-run testing that costs zero tokens.
- A LinkedIn auto-poster that publishes reviewed content and automatically drops your lead magnet link as the first comment.
- Production-grade features including structured logging, health checks, retry with exponential backoff, and a public GitHub repository showcasing the project.
- Secret Mission: Add a content calendar mode that generates a full week of posts with varied formats like stories, hot takes, and tutorial snippets.
Are there any prerequisites?
This project assumes you have an existing Claude Code Pro subscription and basic Python familiarity. You should also have Tool 1 (YouTube analyzer) and Tool 2 (video-to-guide website) functional from earlier in the series.
Set Up the Project Structure and Config File
Your pipeline needs a solid foundation before it can generate a single post. A clean project structure separates concerns (voice files, input data, generated output, logs) so nothing gets tangled together as the system grows.
The real star of this step is the config file. Instead of burying settings like "how many posts to generate" inside Python code, you put them in a plain YAML file anyone can read and edit. This is how enterprise software works.
In this step, get ready to:
- Create the project directory and folder structure.
- Set up configuration, environment, and security files.
- Install dependencies and initialize Claude Code.
Create the project directory and folder structure
Every file your pipeline creates needs a home. You will create a dedicated folder on your Desktop with subdirectories for each type of content the system handles.
- Press the Windows key to open your search bar.
- Type PowerShell and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir sysadmin-pipeline
cd sysadmin-pipeline
- Create the four subdirectories your pipeline needs by running this command:
mkdir voice, input, output, logs
What are these folders for?
- voice/ holds your speaking transcripts and the voice guide Claude follows.
- input/ holds the pain point data from Tool 1 (your YouTube analyzer).
- output/ is where generated posts land for review before publishing.
- logs/ stores timestamped records of every pipeline action.
- Confirm all folders exist by running:
ls
You should see voice, input, output, and logs listed as directories.
Don't see all four folders?
Make sure you ran mkdir voice, input, output, logs from inside the sysadmin-pipeline folder. If you see them created one level up on the Desktop, delete them and re-run after confirming you are inside the project folder with pwd.
Help me fix my folder structure
Create the configuration and security files
Your pipeline reads all its settings from a single config.yaml file. Changing how many posts to generate, how long to wait between API calls, or what post formats to use never requires editing Python code.
- Create a file called config.yaml in your sysadmin-pipeline folder with the following content:
# Sysadmin Content Pipeline - Configuration
# Change these settings without editing any code
pipeline:
num_posts: 3
calendar_posts: 5
rate_limit_delay_seconds: 3
retry_attempts: 3
retry_base_delay_seconds: 10
max_voice_guide_words: 300
content:
min_words: 150
max_words: 250
hashtag_count: "3-5"
cta_style: "direct to first comment"
linkedin:
redirect_uri: "http://localhost:8585/callback"
token_expiry_warning_days: 7
post_confirmation_required: true
logging:
level: "INFO"
log_dir: "logs"
log_file: "pipeline.log"
paths:
voice_guide: "voice_guide.md"
input_file: "input/pain_points.json"
output_dir: "output"
post_log: "post_log.json"
post_formats:
story:
name: "Story Post"
day: "Monday"
instructions: "Tell a short personal story or anecdote that relates to the pain point. Start with 'Last week...' or 'I remember when...'. Keep it real and relatable."
hot_take:
name: "Hot Take"
day: "Tuesday"
instructions: "Open with a bold, slightly contrarian statement. Back it up with one concrete reason. Be confident but not arrogant."
tutorial:
name: "Tutorial Snippet"
day: "Wednesday"
instructions: "Give one specific, actionable mini-tutorial (3-5 steps). Start with 'Here is something you can do in 10 minutes:'. Include actual commands or tool names."
myth_buster:
name: "Myth Buster"
day: "Thursday"
instructions: "Start with 'Myth: [common belief]' then 'Reality: [the truth]'. Debunk with logic. Be empathetic about WHY people believe the myth."
cta:
name: "CTA Post"
day: "Friday"
instructions: "Direct pitch for the free guide. Lead with value. Use social proof if available. Make the CTA crystal clear."
What does this config control?
- pipeline section controls generation behavior: how many posts, retry logic, and rate limiting.
- content section sets word limits and CTA style for generated posts.
- linkedin section holds OAuth callback settings and safety switches.
- paths section tells the pipeline where to find and save everything.
- post_formats section defines five content styles for the Content Calendar feature.
When you want to change any behavior later, you edit this one file instead of hunting through Python code.
- Save config.yaml.
- Open the file and confirm you can read every setting clearly.
Seeing a YAML parsing error later?
YAML is sensitive to indentation. Every nested level must use exactly two spaces (not tabs). Check that none of the lines use tab characters and that all values under a key are indented consistently.
Help me fix a YAML indentation error in my config.yaml
Next, your pipeline needs a safe place to store credentials. The .env file holds secrets that should never end up in your code or on GitHub.
- Create a file called .env in your sysadmin-pipeline folder with the following content:
LINKEDIN_CLIENT_ID=your-client-id-here
LINKEDIN_CLIENT_SECRET=your-client-secret-here
LINKEDIN_REDIRECT_URI=http://localhost:8585/callback
LEAD_MAGNET_URL=https://your-guide-url-here.com
CTA_COMMENT_TEMPLATE=Free guide mentioned above: {lead_magnet_url} - No signup wall, just the guide.
Why placeholder values?
Your LinkedIn Developer App is still pending approval from Step 0. Once approved, you will replace your-client-id-here and your-client-secret-here with your real credentials. For now, the placeholders let you build and test the pipeline structure without waiting.
- Save .env.
Now you need to protect those secrets from accidentally being committed to version control. A .gitignore file tells Git which files to completely ignore.
- Create a file called .gitignore in your sysadmin-pipeline folder with the following content:
.env
linkedin_token.json
output/
logs/
__pycache__/
*.pyc
.venv/
venv/
What is this protecting?
.env keeps your API credentials off GitHub. linkedin_token.json will hold your OAuth token later. The output/ and logs/ directories contain generated content and runtime logs that are specific to your machine.
- Save .gitignore.
Finally, your pipeline needs standardized input data. Tool 1 (your YouTube analyzer) finds sysadmin pain points, and the pipeline expects them in a specific JSON format.
- Create a file called pain_points.json inside your input/ folder with the following example content:
[
{
"topic": "Sysadmins worried AI chatbots will replace tier 1 helpdesk then move up the chain",
"context": "Fear that once AI handles password resets, management will cut headcount. Sentiment: if AI does 80% of tickets, why keep us?",
"source_video": "https://youtube.com/watch?v=example1",
"question_count": 47
},
{
"topic": "Confusion about what skills to learn - Python, AI prompting, or cloud?",
"context": "Paralyzed by too many options. Worried about picking the wrong skill to invest in.",
"source_video": "https://youtube.com/watch?v=example2",
"question_count": 32
},
{
"topic": "Senior sysadmins feeling too old to transition to DevOps or cloud roles",
"context": "40+ sysadmins feeling left behind. Job postings now require Kubernetes, Terraform, CI/CD they never needed.",
"source_video": "https://youtube.com/watch?v=example3",
"question_count": 28
}
]
What does each field mean?
- topic is the core pain point your audience cares about.
- context gives Claude background on why this hurts so the post resonates emotionally.
- source_video tracks where you found the pain point for reference.
- question_count shows demand. Higher count means more people asking about this topic.
Later, you will replace this example data with real output from your Tool 1 YouTube analyzer.
- Save pain_points.json.
File not saving inside input/?
Make sure you are creating the file inside the input subdirectory, not the project root. The full path should be sysadmin-pipeline/input/pain_points.json.
Help me put my pain_points.json in the right directory
✔️ Awesome, I've got everything!
Great. Double check you have saved all four files before moving on.
ⓧ I'd like to double check the full code
Here are all four files as they should look. Compare yours against these:
# Sysadmin Content Pipeline - Configuration
# Change these settings without editing any code
pipeline:
num_posts: 3
calendar_posts: 5
rate_limit_delay_seconds: 3
retry_attempts: 3
retry_base_delay_seconds: 10
max_voice_guide_words: 300
content:
min_words: 150
max_words: 250
hashtag_count: "3-5"
cta_style: "direct to first comment"
linkedin:
redirect_uri: "http://localhost:8585/callback"
token_expiry_warning_days: 7
post_confirmation_required: true
logging:
level: "INFO"
log_dir: "logs"
log_file: "pipeline.log"
paths:
voice_guide: "voice_guide.md"
input_file: "input/pain_points.json"
output_dir: "output"
post_log: "post_log.json"
post_formats:
story:
name: "Story Post"
day: "Monday"
instructions: "Tell a short personal story or anecdote that relates to the pain point. Start with 'Last week...' or 'I remember when...'. Keep it real and relatable."
hot_take:
name: "Hot Take"
day: "Tuesday"
instructions: "Open with a bold, slightly contrarian statement. Back it up with one concrete reason. Be confident but not arrogant."
tutorial:
name: "Tutorial Snippet"
day: "Wednesday"
instructions: "Give one specific, actionable mini-tutorial (3-5 steps). Start with 'Here is something you can do in 10 minutes:'. Include actual commands or tool names."
myth_buster:
name: "Myth Buster"
day: "Thursday"
instructions: "Start with 'Myth: [common belief]' then 'Reality: [the truth]'. Debunk with logic. Be empathetic about WHY people believe the myth."
cta:
name: "CTA Post"
day: "Friday"
instructions: "Direct pitch for the free guide. Lead with value. Use social proof if available. Make the CTA crystal clear."
LINKEDIN_CLIENT_ID=your-client-id-here
LINKEDIN_CLIENT_SECRET=your-client-secret-here
LINKEDIN_REDIRECT_URI=http://localhost:8585/callback
LEAD_MAGNET_URL=https://your-guide-url-here.com
CTA_COMMENT_TEMPLATE=Free guide mentioned above: {lead_magnet_url} - No signup wall, just the guide.
.env
linkedin_token.json
output/
logs/
__pycache__/
*.pyc
.venv/
venv/
[
{
"topic": "Sysadmins worried AI chatbots will replace tier 1 helpdesk then move up the chain",
"context": "Fear that once AI handles password resets, management will cut headcount. Sentiment: if AI does 80% of tickets, why keep us?",
"source_video": "https://youtube.com/watch?v=example1",
"question_count": 47
},
{
"topic": "Confusion about what skills to learn - Python, AI prompting, or cloud?",
"context": "Paralyzed by too many options. Worried about picking the wrong skill to invest in.",
"source_video": "https://youtube.com/watch?v=example2",
"question_count": 32
},
{
"topic": "Senior sysadmins feeling too old to transition to DevOps or cloud roles",
"context": "40+ sysadmins feeling left behind. Job postings now require Kubernetes, Terraform, CI/CD they never needed.",
"source_video": "https://youtube.com/watch?v=example3",
"question_count": 28
}
]
Install dependencies and initialize Claude Code
Your pipeline uses three Python libraries to read config files, load environment secrets, and talk to the LinkedIn API.
- Install the project dependencies by running this command:
pip install python-dotenv requests pyyaml
What do these libraries do?
- python-dotenv loads variables from your .env file so secrets stay out of code.
- requests makes HTTP calls to the LinkedIn API for posting.
- pyyaml reads your config.yaml settings file.
You should see output ending with Successfully installed followed by the package names.
Seeing 'pip is not recognized'?
Try using pip3 instead of pip. If neither works, your Python installation may not be added to your system PATH. Reinstall Python and check the "Add to PATH" box during setup.
Help me fix pip not being found in PowerShell
Now initialize Claude Code in your project directory. This confirms Claude Code can access your project and sets it as the working context.
- Start Claude Code in your project by running:
claude
Claude Code opens an interactive session inside your sysadmin-pipeline folder. You should see a prompt ready for input.
- Type /exit to close the session for now.
Claude Code not starting?
Confirm Claude Code is installed by running claude --version. If it is not found, install it with winget install Anthropic.ClaudeCode and restart your terminal.
Help me get Claude Code running on Windows
Your project skeleton is ready. Every setting lives in config.yaml, every secret is protected in .env, and your input format is defined. Next up, you will teach Claude how YOU talk by building a voice profile from your speaking transcripts.
Build Your Voice Profile
Generic AI content gets scrolled past. Content that sounds like a real person builds trust and gets engagement.
In this step, you will teach Claude Code how YOU talk. You will feed your speaking transcripts into Claude Code and ask it to find your patterns. Things like sentence length, words you always use, phrases you never say. Claude identifies these patterns and you save them as a "voice guide" that every future generation follows.
In this step, get ready to:
- Gather speaking transcripts and analyze your voice patterns with Claude Code.
- Create a token-efficient voice guide that captures your speaking style.
- Add a CLAUDE.md file so Claude Code understands the pipeline every session.
Gather and analyze your speaking transcripts
Your speaking transcripts capture your natural voice better than polished writing would. The goal is to extract patterns Claude can replicate.
- Find 2-3 recordings of yourself speaking. These could be video call transcripts, podcast transcripts, voice memos you transcribed, or even long-form messages you've sent.
- Copy each transcript into a separate text file in your voice/ folder (for example, voice/transcript_1.txt, voice/transcript_2.txt).
No transcripts on hand?
Record yourself talking for 5 minutes about your job. Explain what you do, what frustrates you, and what advice you give new sysadmins. Transcribe it using any free transcription tool. That raw output is exactly what you need.
- Open Claude Code in your project directory (it should already be initialized from Step 1).
- Ask Claude Code to analyze your transcripts by typing something like this directly in the Claude Code session:
Read the files in voice/ and analyze my speaking style. What patterns do you
notice? Look for: sentence length, vocabulary I repeat, tone markers, phrases
I use often, and words I never say. Give me a concise summary I can turn into
a voice guide.
What is Claude Code doing here?
Claude Code reads your transcript files directly from the voice/ folder and identifies linguistic patterns. It looks for things like how long your sentences are, whether you use rhetorical questions, and what filler words you lean on.
This analysis becomes the raw material for your voice guide. You are looking for patterns that actually change output quality, not just interesting trivia about your speech.
- Review the patterns Claude Code identifies. Mark the ones that feel most "you" and discard anything generic.
Create your voice guide
The voice guide ships with every single claude -p call your pipeline makes. Every word in it costs tokens. This means you need it dense, actionable, and under 300 words.
Why 300 words maximum?
The --append-system-prompt-file flag loads your voice guide as system context on every API call. A 300-word guide adds roughly 400 tokens per call. A 1,000-word guide would triple that cost for no extra quality.
Only keep patterns that measurably change the output. If removing a line doesn't make posts sound less like you, cut it.
- Create a file called voice_guide.md in your project root with the following content:
# Voice Guide (KEEP UNDER 300 WORDS - every word ships with every API call)
## How the author speaks
Direct, conversational. Like a coworker at lunch. Empathetic about fears, confident about solutions. Occasional humor, never at audience's expense.
## Patterns
- Short paragraphs (1-2 sentences for mobile)
- Hooks: questions, bold statements, relatable scenarios
- Says "you" constantly. Talks TO reader, not AT them.
- "Look" or "here's the thing" before key points
- "Honestly" or "real talk" before uncomfortable truths
- Technical terms always get plain-English follow-up
## Never says
leverage, synergy, disrupt, ecosystem, thought leader
## LinkedIn rules (NON-NEGOTIABLE)
- ZERO external links in post body
- End with CTA to first comment: "Free guide in the comments" or similar
- Line breaks between every sentence
- 150-250 words
- 3-5 hashtags at very end only
- One specific actionable takeaway per post
## Content guardrails
- Nothing political or controversial
- Never attack people/companies by name
- No income claims or guarantees
- Practical sysadmin career advice only
## Framing
- AI replacing jobs: "The sysadmin who uses AI replaces the one who doesn't."
- Automation fear: "Automation handles boring stuff so you handle important stuff."
- Learning skills: "You don't need to become a developer. Know what to ask for."
## Example phrases
[REPLACE WITH YOUR ACTUAL PHRASES]
What does each section do?
- "How the author speaks" sets the overall tone Claude should mimic.
- "Patterns" gives Claude concrete structural rules (short paragraphs, hooks, pronouns).
- "Never says" prevents Claude from falling back to its default corporate vocabulary.
- "LinkedIn rules" enforces the no-links-in-body strategy. This is non-negotiable because LinkedIn's algorithm penalizes posts containing external links.
- "Example phrases" is where you paste 3-5 phrases from your transcripts that feel most like you.
- Replace the [REPLACE WITH YOUR ACTUAL PHRASES] section with 3-5 phrases from your transcript analysis that feel distinctly like your voice.
- Customize the "How the author speaks", "Patterns", and "Never says" sections to match what Claude Code found in your transcripts.
- Save voice_guide.md.
- Confirm the word count is under 300 by running this in PowerShell:
Get-Content voice_guide.md | Measure-Object -Word
- Check the Words value in the output. If it is over 300, trim any sections that do not measurably change the quality of generated posts.
Word count over 300?
Start by cutting adjectives and filler in your pattern descriptions. Then ask yourself: if I removed this line entirely, would Claude's output be noticeably worse? If not, cut it.
The "Example phrases" section is a common culprit. Keep only 3-5 short phrases, not full sentences.
How do I trim my voice guide without losing quality?
Create CLAUDE.md and verify your voice
Claude Code reads a CLAUDE.md file at the start of every session. This gives Claude persistent context about what your project does and how to behave.
- Create a file called CLAUDE.md in your project root with the following content:
# Sysadmin Content Pipeline
## Project Purpose
Automated content pipeline generating LinkedIn posts for IT sysadmins worried about AI replacing their jobs. Posts reassure them AI makes them MORE valuable while driving free guide downloads.
## Voice Guide
Always follow `voice_guide.md` when generating content. Keep voice guide under 300 words for token efficiency.
## Key Rules
- Target: IT system administrators afraid of automation
- Tone: Empathetic, direct. Never condescending. Never corporate-speak.
- CTA: NEVER put links in post body. Direct to "first comment."
- Safety: Never generate political, controversial, or attacking content
- All secrets in `.env`, never in code
- All settings in `config.yaml`, never hardcoded
## Technical
- Uses `claude -p --append-system-prompt-file voice_guide.md --max-turns 1`
- Input: `input/pain_points.json`
- Output: `output/YYYY-MM-DD/`
- Logs: `logs/pipeline.log`
- Post tracking: `post_log.json`
What does CLAUDE.md do?
Every time you open Claude Code in this project folder, it automatically reads CLAUDE.md and applies its rules. You never have to re-explain the project context. Claude already knows what the pipeline does, where files live, and what rules to follow.
The "Technical" section tells Claude exactly which CLI flags your pipeline uses. This helps Claude give you accurate suggestions when you ask for help later.
- Save CLAUDE.md.
- Now verify that your voice guide actually works. In your Claude Code session, type this test prompt:
Write a 3-sentence LinkedIn post about AI automation using the voice guide.
- Read the output. Check three things: Does it sound like you? Does it end with a "check the comments" style CTA? Are there zero external links in the body?
What if it does not sound like me?
That is normal on the first try. Go back to voice_guide.md and make your pattern descriptions more specific. Instead of "casual tone," try "starts sentences with 'Look' or 'Here's the thing' before making a point." Concrete patterns beat vague adjectives.
Run the test prompt again after each edit. Two or three rounds of refinement is typical before posts sound 90% like you.
Claude Code not reading your voice guide?
Make sure CLAUDE.md is in the project root (the same folder where you run Claude Code), not inside a subfolder.
Confirm that voice_guide.md exists in the project root and is referenced correctly in CLAUDE.md.
Why is Claude Code ignoring my voice guide instructions?
✔️ Awesome, I've got everything!
Great. Make sure both voice_guide.md and CLAUDE.md are saved in your project root.
ⓧ I'd like to double check the full code
Here is the complete voice_guide.md:
# Voice Guide (KEEP UNDER 300 WORDS - every word ships with every API call)
## How the author speaks
Direct, conversational. Like a coworker at lunch. Empathetic about fears, confident about solutions. Occasional humor, never at audience's expense.
## Patterns
- Short paragraphs (1-2 sentences for mobile)
- Hooks: questions, bold statements, relatable scenarios
- Says "you" constantly. Talks TO reader, not AT them.
- "Look" or "here's the thing" before key points
- "Honestly" or "real talk" before uncomfortable truths
- Technical terms always get plain-English follow-up
## Never says
leverage, synergy, disrupt, ecosystem, thought leader
## LinkedIn rules (NON-NEGOTIABLE)
- ZERO external links in post body
- End with CTA to first comment: "Free guide in the comments" or similar
- Line breaks between every sentence
- 150-250 words
- 3-5 hashtags at very end only
- One specific actionable takeaway per post
## Content guardrails
- Nothing political or controversial
- Never attack people/companies by name
- No income claims or guarantees
- Practical sysadmin career advice only
## Framing
- AI replacing jobs: "The sysadmin who uses AI replaces the one who doesn't."
- Automation fear: "Automation handles boring stuff so you handle important stuff."
- Learning skills: "You don't need to become a developer. Know what to ask for."
## Example phrases
[REPLACE WITH YOUR ACTUAL PHRASES]
Here is the complete CLAUDE.md:
# Sysadmin Content Pipeline
## Project Purpose
Automated content pipeline generating LinkedIn posts for IT sysadmins worried about AI replacing their jobs. Posts reassure them AI makes them MORE valuable while driving free guide downloads.
## Voice Guide
Always follow `voice_guide.md` when generating content. Keep voice guide under 300 words for token efficiency.
## Key Rules
- Target: IT system administrators afraid of automation
- Tone: Empathetic, direct. Never condescending. Never corporate-speak.
- CTA: NEVER put links in post body. Direct to "first comment."
- Safety: Never generate political, controversial, or attacking content
- All secrets in `.env`, never in code
- All settings in `config.yaml`, never hardcoded
## Technical
- Uses `claude -p --append-system-prompt-file voice_guide.md --max-turns 1`
- Input: `input/pain_points.json`
- Output: `output/YYYY-MM-DD/`
- Logs: `logs/pipeline.log`
- Post tracking: `post_log.json`
Your voice profile is locked in. Next up, you will build the pipeline script that uses this voice guide to generate full LinkedIn posts from your pain point research.
Build the Pipeline Script
You have your config, voice guide, and pain point data ready. Now you need an engine that reads all of these, sends them to Claude Code, and saves the results.
This script replaces 2-3 hours of manual writing per week. It also has built-in safety features: retry with exponential backoff when calls fail, structured logging so you can see what happened after the fact, and a dry-run mode that tests the full flow without spending a single token.
In this step, get ready to:
- Create pipeline.py with config-driven settings, structured logging, and a dry-run flag.
- Build retry logic with exponential backoff and token-efficient Claude calls.
- Verify the pipeline works in dry-run mode (zero tokens) and then for real.
Create the script foundation
The first section of your pipeline script handles three things: loading your config file, setting up timestamped logging, and pulling all settings into named constants so nothing is hardcoded.
- Create a new file called pipeline.py in your project root folder.
- Add the imports, config loading, and logging setup by pasting this code:
import subprocess
import json
import sys
import time
import logging
import argparse
from pathlib import Path
from datetime import datetime
import yaml
from dotenv import load_dotenv
load_dotenv()
# Load config
CONFIG_PATH = Path("config.yaml")
if not CONFIG_PATH.exists():
print("ERROR: config.yaml not found. Create it first (see Step 1).")
sys.exit(1)
with open(CONFIG_PATH) as f:
CONFIG = yaml.safe_load(f)
# Setup logging
log_dir = Path(CONFIG["logging"]["log_dir"])
log_dir.mkdir(exist_ok=True)
logging.basicConfig(
level=getattr(logging, CONFIG["logging"]["level"]),
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.FileHandler(log_dir / CONFIG["logging"]["log_file"], encoding="utf-8"),
logging.StreamHandler(),
],
)
logger = logging.getLogger(__name__)
# Config values
VOICE_GUIDE_PATH = Path(CONFIG["paths"]["voice_guide"])
INPUT_PATH = Path(CONFIG["paths"]["input_file"])
OUTPUT_DIR = Path(CONFIG["paths"]["output_dir"])
NUM_POSTS = CONFIG["pipeline"]["num_posts"]
RETRY_ATTEMPTS = CONFIG["pipeline"]["retry_attempts"]
RETRY_BASE_DELAY = CONFIG["pipeline"]["retry_base_delay_seconds"]
RATE_LIMIT_DELAY = CONFIG["pipeline"]["rate_limit_delay_seconds"]
MAX_VOICE_WORDS = CONFIG["pipeline"]["max_voice_guide_words"]
What does this code do?
- load_dotenv() loads your .env secrets into environment variables so they never appear in code.
- The config loading reads config.yaml and exits immediately with a clear message if it's missing.
- logging.basicConfig writes timestamped entries to both logs/pipeline.log AND the terminal simultaneously.
- Every setting is pulled from config.yaml into named constants. You change behavior by editing the YAML, never the Python.
- Save pipeline.py.
- Confirm the foundation loads correctly by running:
python pipeline.py
- The script should exit silently with no errors. If you see an error about config.yaml or a missing module, fix that before continuing.
Seeing a ModuleNotFoundError?
- If you see No module named 'yaml', your dependencies from Step 1 may not be installed. Run pip install python-dotenv requests pyyaml again.
- If you see a YAML parse error, open config.yaml and check for indentation issues. YAML is whitespace-sensitive.
Help me debug a ModuleNotFoundError when running pipeline.py
Add the validation and generation functions
Your pipeline needs to validate inputs before burning tokens, call Claude with retry logic when things fail, and construct lean prompts that get good results.
- Add the input validation functions below your constants in pipeline.py:
def load_voice_guide():
if not VOICE_GUIDE_PATH.exists():
logger.error(f"Voice guide not found: {VOICE_GUIDE_PATH}")
sys.exit(1)
content = VOICE_GUIDE_PATH.read_text(encoding="utf-8")
word_count = len(content.split())
if word_count > MAX_VOICE_WORDS:
logger.warning(f"Voice guide is {word_count} words (limit: {MAX_VOICE_WORDS}). Trim for token efficiency!")
return content
def load_pain_points():
if not INPUT_PATH.exists():
logger.error(f"Pain points not found: {INPUT_PATH}")
sys.exit(1)
try:
with open(INPUT_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
except json.JSONDecodeError as e:
logger.error(f"Invalid JSON: {e}")
sys.exit(1)
if not isinstance(data, list):
logger.error("pain_points.json must be a JSON array.")
sys.exit(1)
for i, item in enumerate(data):
if "topic" not in item or "context" not in item:
logger.error(f"Item {i} missing required fields (topic, context).")
sys.exit(1)
return data
What does this code do?
- load_voice_guide() reads your voice guide and warns you if it exceeds the 300-word limit. Every extra word ships with every API call, so bloat costs tokens.
- load_pain_points() validates the JSON structure before the pipeline runs. It checks that the file exists, parses correctly, is an array, and each item has topic and context fields.
- Both functions exit immediately with a clear error if something is wrong. This prevents cryptic failures mid-run.
- Next, add the Claude calling function with retry logic below the validation functions:
def call_claude(prompt, dry_run=False):
if dry_run:
logger.info("[DRY RUN] Would call claude -p with prompt:")
logger.info(f" {prompt[:100]}...")
return "[DRY RUN] Simulated output: This is where your generated post would appear."
for attempt in range(1, RETRY_ATTEMPTS + 1):
cmd = [
"claude", "-p",
"--append-system-prompt-file", str(VOICE_GUIDE_PATH),
"--max-turns", "1",
"--output-format", "text",
prompt,
]
result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8")
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
delay = RETRY_BASE_DELAY * (2 ** (attempt - 1))
if attempt < RETRY_ATTEMPTS:
logger.warning(f"Attempt {attempt} failed. Retrying in {delay}s...")
time.sleep(delay)
else:
logger.error(f"All {RETRY_ATTEMPTS} attempts failed.")
if result.stderr:
logger.error(f"Error: {result.stderr[:200]}")
return None
return None
What does this code do?
This is the core engine of your pipeline. Three design decisions make it production-grade:
- Dry-run mode: When dry_run=True, it logs the prompt and returns fake output. Zero tokens consumed. Perfect for testing prompt changes.
- Token efficiency: --append-system-prompt-file loads your voice guide as system context (not repeated in the user prompt). --max-turns 1 prevents agentic loops. --output-format text avoids JSON wrapper overhead.
- Exponential backoff: If a call fails, it waits 10s, then 20s, then 40s. This avoids hammering a failing service and gives transient issues time to resolve.
Why exponential backoff instead of a fixed delay?
A fixed retry (wait 5s, wait 5s, wait 5s) hammers the service at the same rate. If the service is overloaded, this makes things worse.
Exponential backoff (10s, 20s, 40s) gives increasingly more breathing room. Most transient failures resolve within 20 seconds. Enterprise systems always use this pattern.
- Add the post generation and guide topic functions below call_claude:
def generate_post(pain_point, dry_run=False):
prompt = (
f"Write a LinkedIn post about this sysadmin pain point.\n\n"
f"TOPIC: {pain_point['topic']}\n"
f"CONTEXT: {pain_point['context']}\n\n"
f"Rules: 150-250 words. Hook first line. One actionable takeaway. "
f"End with CTA to first comment for free guide. "
f"No URLs in body. 3-5 hashtags at end. "
f"No politics, no attacking people/companies. "
f"Output ONLY the post text."
)
time.sleep(RATE_LIMIT_DELAY)
return call_claude(prompt, dry_run)
def generate_guide_topic(pain_point, dry_run=False):
prompt = (
f"Suggest ONE YouTube how-to video search query for sysadmins about: "
f"{pain_point['topic']}. "
f"Return the search query on line 1, then one sentence why it resonates."
)
time.sleep(RATE_LIMIT_DELAY)
return call_claude(prompt, dry_run) or "Error generating topic"
What does this code do?
- generate_post() builds a lean prompt with just the pain point data and short rules. The voice guide is already loaded as system context via the flag, so the prompt stays minimal.
- The content guardrail ("No politics, no attacking people/companies") is baked into every prompt. This prevents the AI from generating anything that could damage your reputation.
- generate_guide_topic() suggests a matching video topic for Tool 2. This connects your content pipeline to your guide creation workflow.
- time.sleep(RATE_LIMIT_DELAY) pauses 3 seconds between calls to avoid hitting rate limits on your Pro subscription.
- Add the output saving function below the generation functions:
def save_content(posts, date_str):
output_path = OUTPUT_DIR / date_str
output_path.mkdir(parents=True, exist_ok=True)
for i, post in enumerate(posts, 1):
post_file = output_path / f"post_{i}.md"
content = f"# LinkedIn Post {i}\n\n"
content += f"**Pain Point:** {post['pain_point']}\n\n"
content += f"**Guide Topic for Tool 2:** {post['guide_topic']}\n\n"
content += f"---\n\n"
content += post["content"]
post_file.write_text(content, encoding="utf-8")
summary_file = output_path / "SUMMARY.md"
summary = f"# Content Generated: {date_str}\n\n"
for i, post in enumerate(posts, 1):
summary += f"### Post {i}: {post['pain_point'][:60]}...\n"
summary += f"- File: `post_{i}.md`\n"
summary += f"- Guide: {post['guide_topic'].split(chr(10))[0]}\n\n"
summary += f"## Next: `python post_to_linkedin.py {output_path}\\post_1.md`\n"
summary_file.write_text(summary, encoding="utf-8")
return output_path
What does this code do?
- Creates a dated folder (like output/2026-05-20/) so each run's output is organized separately.
- Each post is saved as a Markdown file with the pain point, guide topic, and generated content included for easy review.
- A SUMMARY.md gives you a quick overview of what was generated and tells you what command to run next.
You won't see output yet. The next section wires everything together with the main function.
Add the main function and run the pipeline
The main function is the orchestrator. It parses command-line flags, loads your data, loops through pain points, generates posts, and saves everything.
- Add the main function and entry point at the bottom of pipeline.py:
def main():
parser = argparse.ArgumentParser(description="Sysadmin Content Pipeline")
parser.add_argument("--dry-run", action="store_true", help="Preview without calling Claude (zero tokens)")
parser.add_argument("--calendar", action="store_true", help="Generate full week (5 posts, varied formats)")
args = parser.parse_args()
num_posts = CONFIG["pipeline"]["calendar_posts"] if args.calendar else NUM_POSTS
mode = "DRY RUN" if args.dry_run else ("CALENDAR" if args.calendar else "STANDARD")
logger.info(f"Pipeline started [{mode}]")
print(f"\n{'=' * 55}")
print(f" Sysadmin Content Pipeline [{mode}]")
print(f"{'=' * 55}")
voice_guide = load_voice_guide()
logger.info(f"Voice guide loaded ({len(voice_guide.split())} words)")
pain_points = load_pain_points()
top_points = pain_points[:num_posts]
logger.info(f"Using {len(top_points)} of {len(pain_points)} pain points")
posts = []
for i, point in enumerate(top_points, 1):
logger.info(f"Generating post {i}/{len(top_points)}: {point['topic'][:50]}")
content = generate_post(point, dry_run=args.dry_run)
if content is None:
logger.warning(f"Post {i} skipped (generation failed)")
continue
guide_topic = generate_guide_topic(point, dry_run=args.dry_run)
posts.append({"pain_point": point["topic"], "content": content, "guide_topic": guide_topic})
date_str = datetime.now().strftime("%Y-%m-%d")
output_path = save_content(posts, date_str)
logger.info(f"Generated {len(posts)} posts to {output_path}")
print(f"\n DONE! {len(posts)} posts in {output_path}")
if args.dry_run:
print(" [DRY RUN - no tokens consumed]")
if __name__ == "__main__":
main()
What does this code do?
- --dry-run flag runs the full pipeline logic without calling Claude. You see exactly what prompts would be sent and what files would be created. Zero tokens consumed.
- --calendar flag generates 5 posts instead of 3 (you will use this in the Secret Mission).
- The loop processes each pain point, generates a post and guide topic, and gracefully skips any that fail. One bad call does not crash the whole run.
- if __name__ == "__main__" ensures the script only runs when called directly, not when imported by another script.
- Save pipeline.py.
- Test the pipeline in dry-run mode first (zero tokens) by running:
python pipeline.py --dry-run
- You should see the pipeline banner, log messages showing "[DRY RUN] Would call claude -p with prompt:" for each pain point, and a "DONE! 3 posts" message at the end.
- Check that the output/ folder now contains a dated subfolder with post_1.md, post_2.md, post_3.md, and SUMMARY.md.
- Also check logs/pipeline.log for timestamped entries. Every action the pipeline took should be recorded there.
Dry-run didn't produce output files?
- If you see "Pain points not found," make sure input/pain_points.json exists and contains valid JSON with topic and context fields in each item.
- If you see "Voice guide not found," confirm voice_guide.md is in the project root (not inside the voice/ folder).
- If you see a Python syntax error, double-check that all parentheses and quotes are matched. The f-strings with curly braces inside are a common place for typos.
My pipeline.py dry-run is failing. Help me debug.
- Once dry-run works, run the pipeline for real by running:
python pipeline.py
- This time Claude will generate real posts. It takes about 30-60 seconds for 3 posts (with the 3-second rate limit between calls).
- Check the output/ folder. Open one of the generated posts. Verify that it has no external links in the body and ends with a "check the first comment" style CTA.
- Open logs/pipeline.log. You should see timestamped entries for each generation step.
Real run failing with retry errors?
- If all 3 retry attempts fail, check that claude is available in your terminal by running claude --version. If it is not found, Claude Code may not be installed or not in your PATH.
- If you see permission errors, make sure you are logged into Claude Code with an active Pro subscription.
Help me fix my pipeline's Claude calls
✔️ Awesome, I've got everything!
Great. Double check you have saved pipeline.py and that both dry-run and real run completed successfully.
ⓧ I'd like to double check the full code
Here is the complete pipeline.py file. Compare yours against this:
import subprocess
import json
import sys
import time
import logging
import argparse
from pathlib import Path
from datetime import datetime
import yaml
from dotenv import load_dotenv
load_dotenv()
# Load config
CONFIG_PATH = Path("config.yaml")
if not CONFIG_PATH.exists():
print("ERROR: config.yaml not found. Create it first (see Step 1).")
sys.exit(1)
with open(CONFIG_PATH) as f:
CONFIG = yaml.safe_load(f)
# Setup logging
log_dir = Path(CONFIG["logging"]["log_dir"])
log_dir.mkdir(exist_ok=True)
logging.basicConfig(
level=getattr(logging, CONFIG["logging"]["level"]),
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.FileHandler(log_dir / CONFIG["logging"]["log_file"], encoding="utf-8"),
logging.StreamHandler(),
],
)
logger = logging.getLogger(__name__)
# Config values
VOICE_GUIDE_PATH = Path(CONFIG["paths"]["voice_guide"])
INPUT_PATH = Path(CONFIG["paths"]["input_file"])
OUTPUT_DIR = Path(CONFIG["paths"]["output_dir"])
NUM_POSTS = CONFIG["pipeline"]["num_posts"]
RETRY_ATTEMPTS = CONFIG["pipeline"]["retry_attempts"]
RETRY_BASE_DELAY = CONFIG["pipeline"]["retry_base_delay_seconds"]
RATE_LIMIT_DELAY = CONFIG["pipeline"]["rate_limit_delay_seconds"]
MAX_VOICE_WORDS = CONFIG["pipeline"]["max_voice_guide_words"]
def load_voice_guide():
if not VOICE_GUIDE_PATH.exists():
logger.error(f"Voice guide not found: {VOICE_GUIDE_PATH}")
sys.exit(1)
content = VOICE_GUIDE_PATH.read_text(encoding="utf-8")
word_count = len(content.split())
if word_count > MAX_VOICE_WORDS:
logger.warning(f"Voice guide is {word_count} words (limit: {MAX_VOICE_WORDS}). Trim for token efficiency!")
return content
def load_pain_points():
if not INPUT_PATH.exists():
logger.error(f"Pain points not found: {INPUT_PATH}")
sys.exit(1)
try:
with open(INPUT_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
except json.JSONDecodeError as e:
logger.error(f"Invalid JSON: {e}")
sys.exit(1)
if not isinstance(data, list):
logger.error("pain_points.json must be a JSON array.")
sys.exit(1)
for i, item in enumerate(data):
if "topic" not in item or "context" not in item:
logger.error(f"Item {i} missing required fields (topic, context).")
sys.exit(1)
return data
def call_claude(prompt, dry_run=False):
if dry_run:
logger.info("[DRY RUN] Would call claude -p with prompt:")
logger.info(f" {prompt[:100]}...")
return "[DRY RUN] Simulated output: This is where your generated post would appear."
for attempt in range(1, RETRY_ATTEMPTS + 1):
cmd = [
"claude", "-p",
"--append-system-prompt-file", str(VOICE_GUIDE_PATH),
"--max-turns", "1",
"--output-format", "text",
prompt,
]
result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8")
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
delay = RETRY_BASE_DELAY * (2 ** (attempt - 1))
if attempt < RETRY_ATTEMPTS:
logger.warning(f"Attempt {attempt} failed. Retrying in {delay}s...")
time.sleep(delay)
else:
logger.error(f"All {RETRY_ATTEMPTS} attempts failed.")
if result.stderr:
logger.error(f"Error: {result.stderr[:200]}")
return None
return None
def generate_post(pain_point, dry_run=False):
prompt = (
f"Write a LinkedIn post about this sysadmin pain point.\n\n"
f"TOPIC: {pain_point['topic']}\n"
f"CONTEXT: {pain_point['context']}\n\n"
f"Rules: 150-250 words. Hook first line. One actionable takeaway. "
f"End with CTA to first comment for free guide. "
f"No URLs in body. 3-5 hashtags at end. "
f"No politics, no attacking people/companies. "
f"Output ONLY the post text."
)
time.sleep(RATE_LIMIT_DELAY)
return call_claude(prompt, dry_run)
def generate_guide_topic(pain_point, dry_run=False):
prompt = (
f"Suggest ONE YouTube how-to video search query for sysadmins about: "
f"{pain_point['topic']}. "
f"Return the search query on line 1, then one sentence why it resonates."
)
time.sleep(RATE_LIMIT_DELAY)
return call_claude(prompt, dry_run) or "Error generating topic"
def save_content(posts, date_str):
output_path = OUTPUT_DIR / date_str
output_path.mkdir(parents=True, exist_ok=True)
for i, post in enumerate(posts, 1):
post_file = output_path / f"post_{i}.md"
content = f"# LinkedIn Post {i}\n\n"
content += f"**Pain Point:** {post['pain_point']}\n\n"
content += f"**Guide Topic for Tool 2:** {post['guide_topic']}\n\n"
content += f"---\n\n"
content += post["content"]
post_file.write_text(content, encoding="utf-8")
summary_file = output_path / "SUMMARY.md"
summary = f"# Content Generated: {date_str}\n\n"
for i, post in enumerate(posts, 1):
summary += f"### Post {i}: {post['pain_point'][:60]}...\n"
summary += f"- File: `post_{i}.md`\n"
summary += f"- Guide: {post['guide_topic'].split(chr(10))[0]}\n\n"
summary += f"## Next: `python post_to_linkedin.py {output_path}\\post_1.md`\n"
summary_file.write_text(summary, encoding="utf-8")
return output_path
def main():
parser = argparse.ArgumentParser(description="Sysadmin Content Pipeline")
parser.add_argument("--dry-run", action="store_true", help="Preview without calling Claude (zero tokens)")
parser.add_argument("--calendar", action="store_true", help="Generate full week (5 posts, varied formats)")
args = parser.parse_args()
num_posts = CONFIG["pipeline"]["calendar_posts"] if args.calendar else NUM_POSTS
mode = "DRY RUN" if args.dry_run else ("CALENDAR" if args.calendar else "STANDARD")
logger.info(f"Pipeline started [{mode}]")
print(f"\n{'=' * 55}")
print(f" Sysadmin Content Pipeline [{mode}]")
print(f"{'=' * 55}")
voice_guide = load_voice_guide()
logger.info(f"Voice guide loaded ({len(voice_guide.split())} words)")
pain_points = load_pain_points()
top_points = pain_points[:num_posts]
logger.info(f"Using {len(top_points)} of {len(pain_points)} pain points")
posts = []
for i, point in enumerate(top_points, 1):
logger.info(f"Generating post {i}/{len(top_points)}: {point['topic'][:50]}")
content = generate_post(point, dry_run=args.dry_run)
if content is None:
logger.warning(f"Post {i} skipped (generation failed)")
continue
guide_topic = generate_guide_topic(point, dry_run=args.dry_run)
posts.append({"pain_point": point["topic"], "content": content, "guide_topic": guide_topic})
date_str = datetime.now().strftime("%Y-%m-%d")
output_path = save_content(posts, date_str)
logger.info(f"Generated {len(posts)} posts to {output_path}")
print(f"\n DONE! {len(posts)} posts in {output_path}")
if args.dry_run:
print(" [DRY RUN - no tokens consumed]")
if __name__ == "__main__":
main()
Your pipeline engine is running. It reads pain points, generates voice-matched posts, saves them for review, and logs everything. Next up, you will test the output quality and fine-tune your voice guide until the posts sound authentically like you.
Test and Refine Your Voice
Your pipeline generates posts, but do they sound like you? The first draft from any AI tool sounds generic until you train it on your specific patterns.
In this step, you will run the pipeline with real data, compare the output against your actual writing, and iterate on your voice guide until the posts are indistinguishable from something you would write manually. The key insight: you only fix the pattern once, and every future generation benefits.
In this step, get ready to:
- Run the pipeline with real pain point data from Tool 1.
- Compare generated posts against your actual LinkedIn writing style.
- Refine your voice guide through dry-run iterations until posts sound 90% like you.
Run the pipeline with your real data
If you have been using example pain points so far, this is where you swap in real output from Tool 1. Your pipeline needs authentic input to produce authentic output.
- Replace the contents of input/pain_points.json with real pain point data from your Tool 1 YouTube analyzer.
- Make sure each item in the JSON array has a topic and context field at minimum.
- Run the pipeline by executing this command:
python pipeline.py
- Open the generated posts in your output/ folder (the dated subfolder from today's run).
- Read each post_1.md, post_2.md, and post_3.md file carefully.
What am I looking for?
You are comparing the generated posts against how you actually write on LinkedIn. Look for specific patterns that feel wrong: sentences that are too formal, words you would never use, a tone that sounds like a corporate blog instead of a person.
Also check the structural rules: no links in the post body, a CTA pointing to the first comment, 150-250 words, and 3-5 hashtags at the end.
Refine your voice guide with dry-run iterations
Every problem you spotted in the output is a missing or unclear instruction in your voice guide. The fix goes into voice_guide.md, not into the pipeline code.
- Open voice_guide.md in your editor.
- For each issue you found, add or update a pattern in the relevant section.
- Replace the [REPLACE WITH YOUR ACTUAL PHRASES] placeholder under Example phrases with 3-5 phrases you actually use in conversation or writing.
Why keep it under 300 words?
Your voice guide ships with every single claude -p call as a system prompt via --append-system-prompt-file. Every extra word costs tokens on every call. A lean guide that captures your key patterns is more effective than a bloated one that repeats itself.
- After editing, preview the updated prompt without spending tokens by running this command:
python pipeline.py --dry-run
- Read the dry-run output to verify your prompt construction looks correct.
- If the prompt looks good, run the real pipeline again:
python pipeline.py
- Open the new posts and compare them against the previous batch.
- Repeat this cycle (edit voice guide, dry-run to verify, real run to test) 2-3 times until the output sounds right.
Common voice guide fixes
- Posts too formal? Add "Never use corporate buzzwords like [list specific offenders]" to the Never says section.
- Sentences too long? Add "Max 2 sentences per paragraph" to the Patterns section.
- Missing your personality? Add actual phrases you say ("here's the deal", "real talk") to the Example phrases section.
- Tone too preachy? Add "Talk WITH reader, not DOWN to them. No lecturing." to the guardrails.
- After each edit, confirm your voice guide is still under 300 words by running this command in PowerShell:
(Get-Content voice_guide.md | Measure-Object -Word).Words
What does this command do?
This reads voice_guide.md and counts the total number of words. If the number is over 300, trim something. Only keep patterns that measurably change the output quality.
Posts still sound generic after 2-3 rounds?
- Check if your Example phrases section has real phrases from your writing, not generic placeholders.
- Make sure your Never says list includes the specific words that keep appearing in the output.
- Try adding a Framing section with your go-to metaphors or analogies for explaining concepts.
Help me debug why my voice guide isn't producing posts that sound like me
Validate with a real person
Your own judgment has blind spots. The ultimate test is whether someone who knows your writing can tell the difference between AI-generated and hand-written.
- Pick your best-sounding generated post from the latest batch.
- Show it to someone who knows your writing (a colleague, friend, or community member).
- Ask them: "Does this sound like me?"
- If they say yes, your voice guide is dialed in. If they point out something off, add that feedback to your voice guide and run one more iteration.
Why 90% and not 100%?
You will always do a quick manual review before posting. The goal is not perfection from the AI. The goal is getting 90% of the way there so your review takes 2 minutes instead of 30.
- Save your finalized voice_guide.md.
- Confirm it is under 300 words.
- Confirm the generated posts have no links in the body and end with a CTA to the first comment.
Your voice is locked in. Next up, you will connect this pipeline directly to LinkedIn so posts go live with a single command and your lead magnet link drops automatically as the first comment.
Set Up LinkedIn Auto-Posting with CTA Comments
Your pipeline is generating voice-matched posts and saving them to review folders. The missing piece is getting those posts from your computer onto LinkedIn automatically.
In this step, you will connect your pipeline to LinkedIn's API so it can publish posts and drop your lead magnet link as the first comment. The script also logs every post with its URL so you can track what you have published.
In this step, get ready to:
- Complete your LinkedIn OAuth 2.0 authentication flow.
- Build an auto-poster script that publishes, comments, and logs.
- Verify your first auto-posted LinkedIn content with CTA comment.
Complete your LinkedIn OAuth 2.0 setup
Before your script can post on your behalf, LinkedIn needs proof that you authorized it. This uses OAuth 2.0, a standard where you log in through your browser once, grant permission, and receive a token your script reuses for 60 days.
- Open your browser and go to https://www.linkedin.com/developers/.
- Select your Developer App (the one you registered in Step 0).
- Click the Auth tab.
- Under OAuth 2.0 settings, confirm that http://localhost:8585/callback is listed as a Redirect URL. If not, add it.
- Under Products, confirm that Share on LinkedIn is approved (this grants the w_member_social scope).
- Copy your Client ID and Client Secret from the Auth tab.
What is OAuth 2.0 doing here?
OAuth 2.0 is a handshake. You visit a LinkedIn URL in your browser, log in, and click Allow. LinkedIn sends an authorization code back to your local machine. Your script exchanges that code for an access token that lasts 60 days.
The token goes into linkedin_token.json (already in your .gitignore). Your posting script reads this file every time it runs.
- Update your .env file with your real Client ID and Client Secret (replacing the placeholder values).
- Use Claude Code interactively to run the OAuth flow. In your terminal, ask Claude Code to help you complete the LinkedIn OAuth 2.0 authorization flow using the credentials in your .env file and http://localhost:8585/callback as the redirect URI with w_member_social scope.
What happens during the OAuth flow?
Claude Code will start a temporary local server on port 8585, open a LinkedIn authorization URL in your browser, catch the callback with the authorization code, and exchange it for an access token. It saves the result to linkedin_token.json.
The token response includes an access_token, an expires_in value (in seconds), and your member URN. Your script needs all three.
- Confirm that linkedin_token.json exists in your project root and contains access_token, expires_at, and author_urn fields.
Developer App not approved yet?
LinkedIn Developer App approval for the Share on LinkedIn product can take a few days. If yours is still pending, continue building the script below. You can complete the OAuth flow and test posting once approval comes through.
help me troubleshoot my LinkedIn Developer App approval
Create the auto-poster script
This script reads a reviewed post file, shows you a preview, publishes it to LinkedIn, immediately drops a CTA comment with your lead magnet link, and logs everything.
- Create an empty post log file by running this command:
echo [] > post_log.json
- Create a new file called post_to_linkedin.py in your project root.
- Add the imports, config loading, and constants by pasting this code:
import json
import os
import sys
import requests
from pathlib import Path
from datetime import datetime, timedelta
from dotenv import load_dotenv
import yaml
import logging
load_dotenv()
# Load config from config.yaml (same settings as pipeline.py)
CONFIG_PATH = Path("config.yaml")
with open(CONFIG_PATH) as f:
CONFIG = yaml.safe_load(f)
# Set up structured logging to the same pipeline.log
log_dir = Path(CONFIG["logging"]["log_dir"])
log_dir.mkdir(exist_ok=True)
logging.basicConfig(
level=getattr(logging, CONFIG["logging"]["level"]),
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.FileHandler(log_dir / CONFIG["logging"]["log_file"], encoding="utf-8"),
logging.StreamHandler(),
],
)
logger = logging.getLogger(__name__)
# Paths and environment variables
TOKEN_PATH = Path("linkedin_token.json")
POST_LOG_PATH = Path(CONFIG["paths"]["post_log"])
LEAD_MAGNET_URL = os.getenv("LEAD_MAGNET_URL", "")
CTA_TEMPLATE = os.getenv("CTA_COMMENT_TEMPLATE", "Free guide: {lead_magnet_url}")
What does this code do?
- Loads your .env secrets and config.yaml settings so the poster shares config with the pipeline.
- Sets up logging to the same logs/pipeline.log file so all actions appear in one place.
- Reads LEAD_MAGNET_URL and CTA_COMMENT_TEMPLATE from your .env for the first-comment CTA.
- Add the token loading function with expiry check below the constants:
def load_token():
"""Load LinkedIn token and check if it's about to expire."""
if not TOKEN_PATH.exists():
logger.error("linkedin_token.json not found. Run OAuth flow first.")
sys.exit(1)
with open(TOKEN_PATH) as f:
token_data = json.load(f)
# Warn if token is nearing expiration
if "expires_at" in token_data:
expires = datetime.fromisoformat(token_data["expires_at"])
days_left = (expires - datetime.now()).days
warn_days = CONFIG["linkedin"]["token_expiry_warning_days"]
if days_left <= 0:
logger.error("LinkedIn token EXPIRED. Re-authenticate.")
sys.exit(1)
elif days_left <= warn_days:
logger.warning(f"LinkedIn token expires in {days_left} days!")
return token_data["access_token"]
What does this code do?
This function reads linkedin_token.json, checks the expires_at timestamp, and warns you 7 days before the token dies. If it is already expired, the script stops immediately so you do not get confusing API errors.
- Add the LinkedIn API functions for publishing a post and posting a comment:
def publish_post(access_token, post_text, author_urn):
"""Publish a text post to LinkedIn using the UGC Posts API."""
url = "https://api.linkedin.com/v2/ugcPosts"
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
"X-Restli-Protocol-Version": "2.0.0",
}
payload = {
"author": author_urn,
"lifecycleState": "PUBLISHED",
"specificContent": {
"com.linkedin.ugc.ShareContent": {
"shareCommentary": {"text": post_text},
"shareMediaCategory": "NONE",
}
},
"visibility": {"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"},
}
response = requests.post(url, headers=headers, json=payload)
response.raise_for_status()
# Grab the post ID from the response header
post_id = response.headers.get("x-restli-id", "")
post_url = f"https://www.linkedin.com/feed/update/{post_id}"
return post_id, post_url
def post_comment(access_token, post_urn, comment_text):
"""Post a comment on the published post (used for CTA link)."""
url = "https://api.linkedin.com/v2/socialActions/{}/comments".format(post_urn)
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
}
payload = {"message": {"text": comment_text}}
response = requests.post(url, headers=headers, json=payload)
response.raise_for_status()
return response.json()
What do these functions do?
- publish_post sends your post text to LinkedIn's UGC Posts API and grabs the post ID from the x-restli-id response header. It constructs the live post URL from that ID.
- post_comment immediately adds your lead magnet CTA as the first comment on the new post. The link goes here instead of the post body because LinkedIn's algorithm penalizes posts with external links.
- Add the post logging function and main execution block:
def log_post(post_file, post_url, pain_point, cta_link):
"""Append this post to the JSON post log for tracking."""
log = []
if POST_LOG_PATH.exists():
with open(POST_LOG_PATH) as f:
log = json.load(f)
log.append({
"title": post_file.stem,
"linkedin_url": post_url,
"date": datetime.now().isoformat(),
"pain_point": pain_point,
"format": "standard",
"cta_link": cta_link,
"engagement": {"likes": None, "comments": None, "reposts": None},
})
with open(POST_LOG_PATH, "w") as f:
json.dump(log, f, indent=2)
def main():
# Check that a post file was provided
if len(sys.argv) < 2:
print("Usage: python post_to_linkedin.py output/YYYY-MM-DD/post_1.md")
sys.exit(1)
post_file = Path(sys.argv[1])
if not post_file.exists():
logger.error(f"Post file not found: {post_file}")
sys.exit(1)
# Read the post content
post_text = post_file.read_text(encoding="utf-8")
# Show preview and ask for confirmation
print("\n" + "=" * 55)
print("POST PREVIEW:")
print(post_text[:500])
print("=" * 55)
confirm = input("Post this to LinkedIn? (y/n): ").strip().lower()
if confirm != "y":
print("Cancelled.")
sys.exit(0)
# Load token and author info
access_token = load_token()
with open(TOKEN_PATH) as f:
token_data = json.load(f)
author_urn = token_data.get("author_urn", "")
# Publish the post
post_id, post_url = publish_post(access_token, post_text, author_urn)
logger.info(f"Post published: {post_url}")
# Post the CTA comment with lead magnet link
cta_comment = CTA_TEMPLATE.format(lead_magnet_url=LEAD_MAGNET_URL)
post_comment(access_token, post_id, cta_comment)
logger.info(f"CTA comment posted")
# Log the post for tracking
pain_point = post_file.parent.name
log_post(post_file, post_url, pain_point, LEAD_MAGNET_URL)
logger.info(f"Logged to {POST_LOG_PATH}")
print(f"\nDone! Post live at: {post_url}")
if __name__ == "__main__":
main()
What does this code do?
- log_post appends each published post to post_log.json with its LinkedIn URL, date, pain point, format, and empty engagement fields you will fill weekly.
- main ties everything together: shows a preview, asks for confirmation (y/n), publishes, comments, and logs.
- The confirmation prompt means nothing posts without your explicit approval. This is your safety net against publishing unreviewed content.
- Save post_to_linkedin.py.
Seeing an import error?
If Python cannot find requests, make sure you installed it in Step 1 with the other dependencies. Run pip install requests to fix it.
help me fix a missing module error in my Python script
✔️ Awesome, I've got everything!
Great. Double check you have saved post_to_linkedin.py and that post_log.json exists as an empty array.
ⓧ I'd like to double check the full code
Here is the complete post_to_linkedin.py for comparison:
import json
import os
import sys
import requests
from pathlib import Path
from datetime import datetime, timedelta
from dotenv import load_dotenv
import yaml
import logging
load_dotenv()
CONFIG_PATH = Path("config.yaml")
with open(CONFIG_PATH) as f:
CONFIG = yaml.safe_load(f)
log_dir = Path(CONFIG["logging"]["log_dir"])
log_dir.mkdir(exist_ok=True)
logging.basicConfig(
level=getattr(logging, CONFIG["logging"]["level"]),
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.FileHandler(log_dir / CONFIG["logging"]["log_file"], encoding="utf-8"),
logging.StreamHandler(),
],
)
logger = logging.getLogger(__name__)
TOKEN_PATH = Path("linkedin_token.json")
POST_LOG_PATH = Path(CONFIG["paths"]["post_log"])
LEAD_MAGNET_URL = os.getenv("LEAD_MAGNET_URL", "")
CTA_TEMPLATE = os.getenv("CTA_COMMENT_TEMPLATE", "Free guide: {lead_magnet_url}")
def load_token():
if not TOKEN_PATH.exists():
logger.error("linkedin_token.json not found. Run OAuth flow first.")
sys.exit(1)
with open(TOKEN_PATH) as f:
token_data = json.load(f)
if "expires_at" in token_data:
expires = datetime.fromisoformat(token_data["expires_at"])
days_left = (expires - datetime.now()).days
warn_days = CONFIG["linkedin"]["token_expiry_warning_days"]
if days_left <= 0:
logger.error("LinkedIn token EXPIRED. Re-authenticate.")
sys.exit(1)
elif days_left <= warn_days:
logger.warning(f"LinkedIn token expires in {days_left} days!")
return token_data["access_token"]
def publish_post(access_token, post_text, author_urn):
url = "https://api.linkedin.com/v2/ugcPosts"
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
"X-Restli-Protocol-Version": "2.0.0",
}
payload = {
"author": author_urn,
"lifecycleState": "PUBLISHED",
"specificContent": {
"com.linkedin.ugc.ShareContent": {
"shareCommentary": {"text": post_text},
"shareMediaCategory": "NONE",
}
},
"visibility": {"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"},
}
response = requests.post(url, headers=headers, json=payload)
response.raise_for_status()
post_id = response.headers.get("x-restli-id", "")
post_url = f"https://www.linkedin.com/feed/update/{post_id}"
return post_id, post_url
def post_comment(access_token, post_urn, comment_text):
url = "https://api.linkedin.com/v2/socialActions/{}/comments".format(post_urn)
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
}
payload = {"message": {"text": comment_text}}
response = requests.post(url, headers=headers, json=payload)
response.raise_for_status()
return response.json()
def log_post(post_file, post_url, pain_point, cta_link):
log = []
if POST_LOG_PATH.exists():
with open(POST_LOG_PATH) as f:
log = json.load(f)
log.append({
"title": post_file.stem,
"linkedin_url": post_url,
"date": datetime.now().isoformat(),
"pain_point": pain_point,
"format": "standard",
"cta_link": cta_link,
"engagement": {"likes": None, "comments": None, "reposts": None},
})
with open(POST_LOG_PATH, "w") as f:
json.dump(log, f, indent=2)
def main():
if len(sys.argv) < 2:
print("Usage: python post_to_linkedin.py output/YYYY-MM-DD/post_1.md")
sys.exit(1)
post_file = Path(sys.argv[1])
if not post_file.exists():
logger.error(f"Post file not found: {post_file}")
sys.exit(1)
post_text = post_file.read_text(encoding="utf-8")
print("\n" + "=" * 55)
print("POST PREVIEW:")
print(post_text[:500])
print("=" * 55)
confirm = input("Post this to LinkedIn? (y/n): ").strip().lower()
if confirm != "y":
print("Cancelled.")
sys.exit(0)
access_token = load_token()
with open(TOKEN_PATH) as f:
token_data = json.load(f)
author_urn = token_data.get("author_urn", "")
post_id, post_url = publish_post(access_token, post_text, author_urn)
logger.info(f"Post published: {post_url}")
cta_comment = CTA_TEMPLATE.format(lead_magnet_url=LEAD_MAGNET_URL)
post_comment(access_token, post_id, cta_comment)
logger.info(f"CTA comment posted")
pain_point = post_file.parent.name
log_post(post_file, post_url, pain_point, LEAD_MAGNET_URL)
logger.info(f"Logged to {POST_LOG_PATH}")
print(f"\nDone! Post live at: {post_url}")
if __name__ == "__main__":
main()
Test your first auto-post
Time to see it in action. You will run the poster script against one of your generated posts from the previous step.
- Pick a reviewed post from your most recent output folder and run the poster by running this command (replace the date with your actual output folder):
python post_to_linkedin.py output/2026-05-20/post_1.md
- The script will display a preview and ask Post this to LinkedIn? (y/n). Type y and press Enter.
- Open LinkedIn in your browser. You should see the new post on your profile with the CTA comment below it containing your lead magnet link.
- Check post_log.json in your project root. It should now have one entry with a linkedin_url, date, and pain_point field.
- Check logs/pipeline.log. You should see timestamped entries for the publish, comment, and log actions.
Getting a 401 or 403 error?
A 401 means your access token is invalid or expired. Re-run the OAuth flow to get a fresh token. A 403 means the w_member_social scope was not granted. Check your Developer App's Products tab to confirm Share on LinkedIn is listed.
help me debug a LinkedIn API authentication error
Why does the CTA go in the first comment?
LinkedIn's algorithm reduces the reach of posts that contain external links in the body. By placing your lead magnet URL in the first comment instead, your post gets full algorithmic distribution while still driving downloads.
Your pipeline now goes from research all the way to published LinkedIn content with a single command. Next up, you will add a health check system and get this project onto GitHub as a portfolio piece.
Add Health Check, Self-Improvement Features, and Push to GitHub
Your pipeline generates posts, publishes them to LinkedIn, and logs everything. But how do you know if the system is healthy before each run?
In this step, you will build a one-command health check that reports system status, a performance tracker that shows what content works best, and then push everything to GitHub as a public portfolio piece.
In this step, get ready to:
- Create a health check script that reports the status of all pipeline components.
- Build a performance tracker for engagement data and voice drift detection.
- Push your project to GitHub with a README, LICENSE, and pinned repo.
Create the health check script
Enterprise tools have a status dashboard. Your pipeline needs the same thing. The health check script runs one command and tells you if your config is valid, your LinkedIn token is active, and your input data is ready.
- Create a new file called health_check.py in your project root by running:
New-Item health_check.py
- Add the following code to health_check.py:
"""Health Check - Run before every pipeline session."""
import json
import os
import sys
from pathlib import Path
from datetime import datetime
import yaml
from dotenv import load_dotenv
load_dotenv()
def check_mark(ok):
return "OK" if ok else "ISSUE"
def main():
print("=" * 55)
print(" Pipeline Health Check")
print(f" {datetime.now().strftime('%Y-%m-%d %H:%M')}")
print("=" * 55)
issues = []
print("\n[Config]")
config_path = Path("config.yaml")
if config_path.exists():
try:
with open(config_path) as f:
config = yaml.safe_load(f)
print(f" config.yaml: {check_mark(True)}")
except Exception as e:
print(f" config.yaml: {check_mark(False)} - Parse error: {e}")
issues.append("config.yaml has errors")
config = {}
else:
print(f" config.yaml: {check_mark(False)} - NOT FOUND")
issues.append("config.yaml missing")
config = {}
print("\n[Voice Guide]")
vg_path = Path(config.get("paths", {}).get("voice_guide", "voice_guide.md"))
if vg_path.exists():
word_count = len(vg_path.read_text(encoding="utf-8").split())
max_words = config.get("pipeline", {}).get("max_voice_guide_words", 300)
ok = word_count <= max_words
status = f"{word_count} words (limit: {max_words})"
if not ok:
status += " - OVER LIMIT (wasting tokens!)"
issues.append(f"Voice guide over {max_words} word limit")
print(f" voice_guide.md: {check_mark(ok)} - {status}")
else:
print(f" voice_guide.md: {check_mark(False)} - NOT FOUND")
issues.append("voice_guide.md missing")
print("\n[Input Data]")
input_path = Path(config.get("paths", {}).get("input_file", "input/pain_points.json"))
if input_path.exists():
try:
with open(input_path) as f:
points = json.load(f)
print(f" pain_points.json: {check_mark(True)} - {len(points)} pain points available")
except Exception as e:
print(f" pain_points.json: {check_mark(False)} - {e}")
issues.append("pain_points.json invalid")
else:
print(f" pain_points.json: {check_mark(False)} - NOT FOUND")
issues.append("No pain points file")
print("\n[LinkedIn]")
token_path = Path("linkedin_token.json")
if token_path.exists():
with open(token_path) as f:
token_data = json.load(f)
if "expires_at" in token_data:
expires = datetime.fromisoformat(token_data["expires_at"])
days_left = (expires - datetime.now()).days
if days_left <= 0:
print(f" Token: {check_mark(False)} - EXPIRED")
issues.append("LinkedIn token expired")
elif days_left <= 7:
print(f" Token: {check_mark(False)} - Expires in {days_left} days!")
issues.append(f"Token expires in {days_left} days")
else:
print(f" Token: {check_mark(True)} - Valid ({days_left} days remaining)")
else:
print(f" Token: {check_mark(False)} - Not authenticated yet")
issues.append("LinkedIn not authenticated")
print("\n[Secrets]")
has_client = bool(os.getenv("LINKEDIN_CLIENT_ID"))
has_secret = bool(os.getenv("LINKEDIN_CLIENT_SECRET"))
has_lead = bool(os.getenv("LEAD_MAGNET_URL"))
print(f" LINKEDIN_CLIENT_ID: {check_mark(has_client)}")
print(f" LINKEDIN_CLIENT_SECRET: {check_mark(has_secret)}")
print(f" LEAD_MAGNET_URL: {check_mark(has_lead)}")
if not has_client or not has_secret:
issues.append("LinkedIn credentials missing from .env")
print("\n[Post Log]")
log_path = Path(config.get("paths", {}).get("post_log", "post_log.json"))
if log_path.exists():
with open(log_path) as f:
log = json.load(f)
total = len(log)
missing_engagement = sum(1 for p in log if p.get("engagement", {}).get("likes") is None)
print(f" Total posts: {total}")
print(f" Missing engagement data: {missing_engagement}")
if missing_engagement > 5:
issues.append(f"{missing_engagement} posts need engagement data updated")
else:
print(f" No posts logged yet")
print("\n[Last Run]")
output_dir = Path(config.get("paths", {}).get("output_dir", "output"))
if output_dir.exists():
folders = sorted(output_dir.iterdir(), reverse=True)
if folders:
print(f" Last output: {folders[0].name}")
else:
print(f" No runs yet")
else:
print(f" Output directory not created yet")
print(f"\n{'=' * 55}")
if issues:
print(f" {len(issues)} issue(s) found:")
for issue in issues:
print(f" - {issue}")
else:
print(" All systems healthy. Ready to run!")
print("=" * 55)
if __name__ == "__main__":
main()
What does this script do?
The health check validates every component of your pipeline in sequence:
- Config validation confirms config.yaml parses without errors.
- Voice guide word count ensures you are not wasting tokens with a bloated system prompt.
- Input data check confirms your pain points file exists and contains valid JSON.
- LinkedIn token status warns you before your token expires (7-day advance warning).
- Secrets check verifies all required environment variables are loaded from .env.
- Post log stats show how many posts you have published and how many are missing engagement data.
- Save health_check.py.
- Run the health check by executing:
python health_check.py
You should see a formatted report with section headers like [Config], [Voice Guide], [LinkedIn], and a summary at the bottom.
Seeing import errors?
Make sure you installed all dependencies earlier with pip install python-dotenv requests pyyaml. If you see a ModuleNotFoundError for yaml, the package name is pyyaml (not yaml).
help me debug a ModuleNotFoundError in my health_check.py script
Build the performance tracker
Once you start posting weekly, you need to know what resonates with your audience. The performance script analyzes your post log and shows which pain points and formats drive the most engagement.
- Create a new file called performance.py in your project root by running:
New-Item performance.py
- Add the following code to performance.py:
import json
from pathlib import Path
from collections import defaultdict
import yaml
with open("config.yaml") as f:
CONFIG = yaml.safe_load(f)
POST_LOG_PATH = Path(CONFIG["paths"]["post_log"])
def main():
if not POST_LOG_PATH.exists():
print("No post_log.json found. Post some content first.")
return
with open(POST_LOG_PATH) as f:
log = json.load(f)
if not log:
print("No posts logged yet.")
return
print("\n" + "=" * 55)
print(" Performance Report")
print("=" * 55)
by_format = defaultdict(list)
by_pain_point = defaultdict(list)
for post in log:
likes = post.get("engagement", {}).get("likes") or 0
fmt = post.get("format", "unknown")
pain = post.get("pain_point", "unknown")[:50]
by_format[fmt].append(likes)
by_pain_point[pain].append(likes)
print("\nTop Formats:")
for fmt, likes_list in sorted(by_format.items(), key=lambda x: sum(x[1]), reverse=True):
avg = sum(likes_list) / len(likes_list) if likes_list else 0
print(f" {fmt}: avg {avg:.1f} likes ({len(likes_list)} posts)")
print("\nTop Pain Points:")
for pain, likes_list in sorted(by_pain_point.items(), key=lambda x: sum(x[1]), reverse=True)[:5]:
avg = sum(likes_list) / len(likes_list) if likes_list else 0
print(f" {pain[:40]}: avg {avg:.1f} likes")
print("=" * 55)
if __name__ == "__main__":
main()
What does this script do?
The performance tracker reads your post_log.json and groups posts by format and pain point. It calculates average likes for each group so you can see which topics and post styles drive the most engagement.
Each week, you manually update the engagement fields in post_log.json with your actual likes, comments, and reposts from LinkedIn. Over time, this data helps you double down on what works.
- Save performance.py.
- Test it by running:
python performance.py
Since your post log is empty, you should see the message "No posts logged yet." This is expected. The report will populate after you publish your first posts.
What about voice drift detection?
Voice drift is when your generated content slowly stops sounding like you over time. To detect it, periodically ask Claude Code to compare your recent published posts against voice_guide.md and suggest updates.
You can do this interactively in Claude Code by pasting a recent post and asking: "Does this match the voice guide? What patterns have drifted?" Update your voice guide based on the feedback.
Script errors when post_log.json is empty?
The script handles an empty array gracefully by printing "No posts logged yet." If you get a JSON decode error, make sure post_log.json contains [] (an empty array) and not completely empty content.
help me fix a JSON error in my performance.py script
Initialize Git and push to GitHub
Your pipeline is complete. Now you will put it on GitHub so it serves as a public portfolio piece. Recruiters and employers can see your production-grade automation skills.
- Create a README.md file in your project root with the following content:
# Sysadmin Content Pipeline
AI-powered LinkedIn content automation for IT sysadmins. Turns YouTube pain point research into voice-matched posts, auto-publishes with CTA comments, and logs everything.
## What It Does
- Reads sysadmin pain points from Tool 1 (YouTube analyzer)
- Generates LinkedIn posts in your voice using Claude Code CLI
- Auto-posts to LinkedIn with lead magnet CTA in first comment
- Logs every post with URL, date, and engagement tracking
- Self-heals with retry logic and token expiry warnings
- Dry-run mode for zero-token testing
## Setup
1. Clone this repo
2. `pip install python-dotenv requests pyyaml`
3. Copy `.env.example` to `.env` and fill in credentials
4. Add your pain points to `input/pain_points.json`
5. Run `python health_check.py`
## Usage
- `python pipeline.py --dry-run` - Test without spending tokens
- `python pipeline.py` - Generate posts
- `python post_to_linkedin.py output/YYYY-MM-DD/post_1.md` - Publish
- `python health_check.py` - System status
- `python performance.py` - Engagement report
## License
MIT
Why a good README matters
The README is the first thing anyone sees on your GitHub repo page. It tells recruiters what the project does, how to set it up, and how to use it. A clear README signals professionalism.
- Create a LICENSE file in your project root with the MIT License:
MIT License
Copyright (c) 2025
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED.
- Confirm your .gitignore already contains the following entries (it should from Step 1):
.env
linkedin_token.json
output/
logs/
__pycache__/
*.pyc
.venv/
venv/
Why these files are ignored
Your .env contains secrets. Your linkedin_token.json holds OAuth credentials. The output/ and logs/ folders contain generated content and session data that should not be in source control.
- Initialize a Git repository and make your first commit by running these commands:
git init -b main
git add .
git commit -m "Initial commit: content pipeline with health check and performance tracking"
What do these commands do?
- git init -b main creates a new Git repository with main as the default branch name.
- git add . stages all files (except those in .gitignore) for your first commit.
- git commit -m "..." saves a snapshot of your project with a descriptive message.
- Now create a new repository on GitHub. Go to https://github.com/new in your browser.
- Enter a repository name like sysadmin-content-pipeline.
- Select Public visibility.
- Do NOT initialize with a README, license, or .gitignore (you already have these locally).
- Click Create repository.
- Copy the remote URL from the Quick Setup page. Then connect your local repo and push by running:
git remote add origin [[REPO_URL="https://github.com/YOUR-USERNAME/sysadmin-content-pipeline.git"]]
git push -u origin main
- Finally, pin the repo on your GitHub profile. Navigate to your GitHub profile page.
- Click Customize your pins.
- Check the box next to your new sysadmin-content-pipeline repository.
- Click Save pins.
Refresh your GitHub profile. You should see your pinned repository with the README description displayed.
Push rejected or authentication error?
If git push fails, you may need to authenticate. GitHub recommends using GitHub CLI (gh auth login) or setting up an SSH key. Make sure you created the remote repository as completely empty (no README or LICENSE initialized on GitHub).
help me fix a git push authentication error to GitHub
- Run the health check one final time to confirm everything is green:
python health_check.py
Every section should show OK status. If the LinkedIn token section shows "Not authenticated yet," that is expected if your Developer App approval is still pending.
✔️ Awesome, I've got everything!
Great. Double check you have saved all files: health_check.py, performance.py, README.md, and LICENSE.
ⓧ I'd like to double check the full code
Here is the complete health_check.py:
"""Health Check - Run before every pipeline session."""
import json
import os
import sys
from pathlib import Path
from datetime import datetime
import yaml
from dotenv import load_dotenv
load_dotenv()
def check_mark(ok):
return "OK" if ok else "ISSUE"
def main():
print("=" * 55)
print(" Pipeline Health Check")
print(f" {datetime.now().strftime('%Y-%m-%d %H:%M')}")
print("=" * 55)
issues = []
print("\n[Config]")
config_path = Path("config.yaml")
if config_path.exists():
try:
with open(config_path) as f:
config = yaml.safe_load(f)
print(f" config.yaml: {check_mark(True)}")
except Exception as e:
print(f" config.yaml: {check_mark(False)} - Parse error: {e}")
issues.append("config.yaml has errors")
config = {}
else:
print(f" config.yaml: {check_mark(False)} - NOT FOUND")
issues.append("config.yaml missing")
config = {}
print("\n[Voice Guide]")
vg_path = Path(config.get("paths", {}).get("voice_guide", "voice_guide.md"))
if vg_path.exists():
word_count = len(vg_path.read_text(encoding="utf-8").split())
max_words = config.get("pipeline", {}).get("max_voice_guide_words", 300)
ok = word_count <= max_words
status = f"{word_count} words (limit: {max_words})"
if not ok:
status += " - OVER LIMIT (wasting tokens!)"
issues.append(f"Voice guide over {max_words} word limit")
print(f" voice_guide.md: {check_mark(ok)} - {status}")
else:
print(f" voice_guide.md: {check_mark(False)} - NOT FOUND")
issues.append("voice_guide.md missing")
print("\n[Input Data]")
input_path = Path(config.get("paths", {}).get("input_file", "input/pain_points.json"))
if input_path.exists():
try:
with open(input_path) as f:
points = json.load(f)
print(f" pain_points.json: {check_mark(True)} - {len(points)} pain points available")
except Exception as e:
print(f" pain_points.json: {check_mark(False)} - {e}")
issues.append("pain_points.json invalid")
else:
print(f" pain_points.json: {check_mark(False)} - NOT FOUND")
issues.append("No pain points file")
print("\n[LinkedIn]")
token_path = Path("linkedin_token.json")
if token_path.exists():
with open(token_path) as f:
token_data = json.load(f)
if "expires_at" in token_data:
expires = datetime.fromisoformat(token_data["expires_at"])
days_left = (expires - datetime.now()).days
if days_left <= 0:
print(f" Token: {check_mark(False)} - EXPIRED")
issues.append("LinkedIn token expired")
elif days_left <= 7:
print(f" Token: {check_mark(False)} - Expires in {days_left} days!")
issues.append(f"Token expires in {days_left} days")
else:
print(f" Token: {check_mark(True)} - Valid ({days_left} days remaining)")
else:
print(f" Token: {check_mark(False)} - Not authenticated yet")
issues.append("LinkedIn not authenticated")
print("\n[Secrets]")
has_client = bool(os.getenv("LINKEDIN_CLIENT_ID"))
has_secret = bool(os.getenv("LINKEDIN_CLIENT_SECRET"))
has_lead = bool(os.getenv("LEAD_MAGNET_URL"))
print(f" LINKEDIN_CLIENT_ID: {check_mark(has_client)}")
print(f" LINKEDIN_CLIENT_SECRET: {check_mark(has_secret)}")
print(f" LEAD_MAGNET_URL: {check_mark(has_lead)}")
if not has_client or not has_secret:
issues.append("LinkedIn credentials missing from .env")
print("\n[Post Log]")
log_path = Path(config.get("paths", {}).get("post_log", "post_log.json"))
if log_path.exists():
with open(log_path) as f:
log = json.load(f)
total = len(log)
missing_engagement = sum(1 for p in log if p.get("engagement", {}).get("likes") is None)
print(f" Total posts: {total}")
print(f" Missing engagement data: {missing_engagement}")
if missing_engagement > 5:
issues.append(f"{missing_engagement} posts need engagement data updated")
else:
print(f" No posts logged yet")
print("\n[Last Run]")
output_dir = Path(config.get("paths", {}).get("output_dir", "output"))
if output_dir.exists():
folders = sorted(output_dir.iterdir(), reverse=True)
if folders:
print(f" Last output: {folders[0].name}")
else:
print(f" No runs yet")
else:
print(f" Output directory not created yet")
print(f"\n{'=' * 55}")
if issues:
print(f" {len(issues)} issue(s) found:")
for issue in issues:
print(f" - {issue}")
else:
print(" All systems healthy. Ready to run!")
print("=" * 55)
if __name__ == "__main__":
main()
Here is the complete performance.py:
import json
from pathlib import Path
from collections import defaultdict
import yaml
with open("config.yaml") as f:
CONFIG = yaml.safe_load(f)
POST_LOG_PATH = Path(CONFIG["paths"]["post_log"])
def main():
if not POST_LOG_PATH.exists():
print("No post_log.json found. Post some content first.")
return
with open(POST_LOG_PATH) as f:
log = json.load(f)
if not log:
print("No posts logged yet.")
return
print("\n" + "=" * 55)
print(" Performance Report")
print("=" * 55)
by_format = defaultdict(list)
by_pain_point = defaultdict(list)
for post in log:
likes = post.get("engagement", {}).get("likes") or 0
fmt = post.get("format", "unknown")
pain = post.get("pain_point", "unknown")[:50]
by_format[fmt].append(likes)
by_pain_point[pain].append(likes)
print("\nTop Formats:")
for fmt, likes_list in sorted(by_format.items(), key=lambda x: sum(x[1]), reverse=True):
avg = sum(likes_list) / len(likes_list) if likes_list else 0
print(f" {fmt}: avg {avg:.1f} likes ({len(likes_list)} posts)")
print("\nTop Pain Points:")
for pain, likes_list in sorted(by_pain_point.items(), key=lambda x: sum(x[1]), reverse=True)[:5]:
avg = sum(likes_list) / len(likes_list) if likes_list else 0
print(f" {pain[:40]}: avg {avg:.1f} likes")
print("=" * 55)
if __name__ == "__main__":
main()
Here is the complete README.md:
# Sysadmin Content Pipeline
AI-powered LinkedIn content automation for IT sysadmins. Turns YouTube pain point research into voice-matched posts, auto-publishes with CTA comments, and logs everything.
## What It Does
- Reads sysadmin pain points from Tool 1 (YouTube analyzer)
- Generates LinkedIn posts in your voice using Claude Code CLI
- Auto-posts to LinkedIn with lead magnet CTA in first comment
- Logs every post with URL, date, and engagement tracking
- Self-heals with retry logic and token expiry warnings
- Dry-run mode for zero-token testing
## Setup
1. Clone this repo
2. `pip install python-dotenv requests pyyaml`
3. Copy `.env.example` to `.env` and fill in credentials
4. Add your pain points to `input/pain_points.json`
5. Run `python health_check.py`
## Usage
- `python pipeline.py --dry-run` - Test without spending tokens
- `python pipeline.py` - Generate posts
- `python post_to_linkedin.py output/YYYY-MM-DD/post_1.md` - Publish
- `python health_check.py` - System status
- `python performance.py` - Engagement report
## License
MIT
Here is the complete LICENSE:
MIT License
Copyright (c) 2025
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED.
Your pipeline now has observability, self-improvement features, and lives on GitHub. Next up, you will package everything into a repeatable weekly workflow and export the project as a shareable guide.
Set Up Your Weekly Workflow and Export the Guide
Your pipeline is live on GitHub, your health check is green, and every component works independently.
Now you need the final two pieces that make this a production system instead of a collection of scripts: a one-click launcher that runs everything from a single command, and an export script that compiles the full project into a shareable guide. The guide becomes both your lead magnet and your portfolio piece.
In this step, get ready to:
- Create a one-click launcher batch file.
- Document your repeatable weekly workflow.
- Export the full project as a shareable guide and verify the end-to-end workflow.
Create the one-click launcher
Running python pipeline.py with flags every time is fine, but a batch file turns it into a double-click operation. It also makes it easy to switch between standard, dry-run, and calendar modes without remembering flag syntax.
- Create a new file called run_pipeline.bat in the root of your project folder by running:
New-Item run_pipeline.bat
- Open run_pipeline.bat in your editor and paste the following code:
@echo off
echo ============================================
echo Sysadmin Content Pipeline
echo ============================================
echo.
cd /d "%~dp0"
if "%1"=="--dry-run" (
echo Mode: DRY RUN [zero tokens]
python pipeline.py --dry-run
) else if "%1"=="--calendar" (
echo Mode: Content Calendar [5 posts]
python pipeline.py --calendar
) else (
echo Mode: Standard [3 posts]
python pipeline.py
)
echo.
pause
What does this script do?
- cd /d "%~dp0" changes to the folder where the batch file lives. This means paths always resolve correctly no matter where you launch it from.
- The if blocks check the first argument you pass to the script. Double-clicking runs standard mode. Running run_pipeline.bat --dry-run from the terminal triggers dry-run mode.
- pause keeps the window open so you can read the output before it closes.
- Save run_pipeline.bat.
- Test the launcher in dry-run mode by running:
.\run_pipeline.bat --dry-run
You should see the pipeline header, "Mode: DRY RUN [zero tokens]", and simulated output. No tokens consumed.
Seeing 'python is not recognized'?
- Make sure Python is added to your system PATH. Open a fresh PowerShell window and run python --version to confirm.
- If you installed Python from the Microsoft Store, try replacing python with python3 in the batch file.
Why is my batch file not running the pipeline?
Plan your weekly workflow
A tool you never use is worthless. The weekly workflow below turns your pipeline into a repeatable system that runs on autopilot.
- Monday AM: Run python health_check.py to verify everything is healthy. Then run run_pipeline.bat to generate that week's content.
- Tuesday: Review and edit the generated posts in your output/ folder. Tweak anything that sounds off.
- Wednesday through Friday: Run python post_to_linkedin.py on one approved post per day. One post per day keeps engagement high without overwhelming your audience.
- Saturday: Open post_log.json and update the engagement fields (likes, comments, reposts) for each post from that week.
- Sunday: Run python performance.py to see what pain points and formats performed best. Use these insights for next week.
Why this rhythm works
Generating all posts on Monday means you batch the AI work into one session. Reviewing on Tuesday gives you fresh eyes. Posting one per day (not all at once) maximizes reach because LinkedIn's algorithm rewards consistent daily activity over bulk dumps.
The Saturday engagement update closes the feedback loop. Without it, your performance data stays empty and you lose the ability to make data-driven content decisions.
Export the project guide and verify the full workflow
The export script compiles every important file in your project into a single Markdown document. This becomes both a shareable guide for your audience and a portfolio piece you can hand to interviewers.
- Create export_guide.py in your project root by running:
New-Item export_guide.py
- Open export_guide.py and paste the following code:
from pathlib import Path
from datetime import datetime
def main():
# List of project files to include in the exported guide
sections = [
"README.md",
"CLAUDE.md",
"voice_guide.md",
"config.yaml",
"pipeline.py",
"post_to_linkedin.py",
"health_check.py",
"performance.py",
"run_pipeline.bat",
]
# Build the guide header with today's date
guide = f"# Sysadmin Content Pipeline - Complete Project Guide\n"
guide += f"Generated: {datetime.now().strftime('%Y-%m-%d')}\n\n"
# Loop through each file and wrap its content in a code block
for section in sections:
path = Path(section)
if path.exists():
guide += f"\n## {section}\n\n```\n"
guide += path.read_text(encoding="utf-8")
guide += "\n```\n"
# Write the compiled guide to a single output file
output = Path("PROJECT_GUIDE.md")
output.write_text(guide, encoding="utf-8")
print(f"Guide exported to {output}")
if __name__ == "__main__":
main()
What does this script do?
It loops through every important file in your project, reads its content, and wraps it in a Markdown code block. The result is a single PROJECT_GUIDE.md file that contains your entire pipeline in a format anyone can follow.
Because it uses Path.exists() checks, it gracefully skips any missing files instead of crashing. If you add new scripts later, just add their filename to the sections list.
- Save export_guide.py.
- Run the export script by running:
python export_guide.py
You should see Guide exported to PROJECT_GUIDE.md printed in your terminal.
- Open PROJECT_GUIDE.md in your editor and confirm it contains all your project files compiled into one document.
Convert to PDF for sharing
To create a PDF version you can share with your audience, open PROJECT_GUIDE.md in VS Code, use the Markdown preview (Ctrl+Shift+V), then print to PDF from your browser. Alternatively, paste the Markdown into any online Markdown-to-PDF converter.
- Verify the guide works as a standalone document. Feed it to Claude Code by running:
cat PROJECT_GUIDE.md | claude -p "What does this project do? Summarize in 3 sentences."
Claude should accurately describe your pipeline: generating LinkedIn posts from pain points, auto-posting with CTA comments, and logging everything. If it gets it right, your guide is complete and clear enough to share.
Claude's summary does not match what you built?
- Check that PROJECT_GUIDE.md is not empty. Run type PROJECT_GUIDE.md to see its contents in the terminal.
- Make sure all your project files (pipeline.py, post_to_linkedin.py, etc.) are in the same folder as export_guide.py. The script looks for files relative to where it runs.
Why is my exported guide missing files or mostly empty?
- Now run the full Monday workflow end-to-end. Start with the health check:
python health_check.py
- Confirm all checks show OK.
- Generate fresh posts by running the pipeline:
python pipeline.py
- Check your output/ folder for new dated posts.
- Publish one post to LinkedIn with the CTA comment by running:
python post_to_linkedin.py output/2026-05-20/post_1.md
- Check LinkedIn for your post with the lead magnet comment below it.
- Verify post_log.json has a new entry with the post URL.
- Verify logs/pipeline.log has timestamped entries from the run.
What should I see?
Your post_log.json should now have at least one entry with fields for the post title, LinkedIn URL, date, pain point, and CTA link.
Your logs/pipeline.log should show timestamped lines recording every action the pipeline took during the run.
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files.
ⓧ I'd like to double check the full code
Here is the full run_pipeline.bat:
@echo off
echo ============================================
echo Sysadmin Content Pipeline
echo ============================================
echo.
cd /d "%~dp0"
if "%1"=="--dry-run" (
echo Mode: DRY RUN [zero tokens]
python pipeline.py --dry-run
) else if "%1"=="--calendar" (
echo Mode: Content Calendar [5 posts]
python pipeline.py --calendar
) else (
echo Mode: Standard [3 posts]
python pipeline.py
)
echo.
pause
Here is the full export_guide.py:
from pathlib import Path
from datetime import datetime
def main():
sections = [
"README.md",
"CLAUDE.md",
"voice_guide.md",
"config.yaml",
"pipeline.py",
"post_to_linkedin.py",
"health_check.py",
"performance.py",
"run_pipeline.bat",
]
guide = f"# Sysadmin Content Pipeline - Complete Project Guide\n"
guide += f"Generated: {datetime.now().strftime('%Y-%m-%d')}\n\n"
for section in sections:
path = Path(section)
if path.exists():
guide += f"\n## {section}\n\n```\n"
guide += path.read_text(encoding="utf-8")
guide += "\n```\n"
output = Path("PROJECT_GUIDE.md")
output.write_text(guide, encoding="utf-8")
print(f"Guide exported to {output}")
if __name__ == "__main__":
main()
Your content engine is fully operational. You have a one-click launcher, a documented weekly rhythm, and a shareable guide that proves to employers and your audience what you can build.
Secret mission
Add Content Calendar Mode
Your pipeline generates 3 posts at a time. What if you could generate a full week of varied content in one command? In this secret mission, you'll add a --calendar flag that produces 5 posts, each in a different format (story, hot take, tutorial snippet, myth buster, CTA), with a posting schedule and duplicate-avoidance logic built in.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs locally and uses your existing Claude Code Pro subscription, so there are no ongoing costs beyond what you already pay.
Resources you used:
- Local project folder on your Desktop (all scripts, config, voice guide, output, logs).
- LinkedIn Developer App (registered at linkedin.com/developers).
- GitHub public repository.
- Installed Python packages (python-dotenv, requests, pyyaml).
Keep everything running
No action needed. Choose this if you are still actively using the pipeline to generate and post content weekly.
- Your project folder stays on your Desktop, ready for your next Monday pipeline run.
- Your LinkedIn Developer App stays active for auto-posting.
- Your GitHub repository remains public as a portfolio piece.
- Your LinkedIn OAuth token will expire after approximately 60 days. Run python health_check.py periodically to check its status.
Pause - I'll come back to this later
Free up local resources while keeping everything intact for later. Your project files and accounts stay in place.
- Close any terminal windows running the pipeline.
- Your LinkedIn OAuth token will likely expire while you are away. When you return, re-authenticate by running post_to_linkedin.py and completing the browser login again.
- Run python health_check.py when you come back to see what needs attention before your next run.
Delete - I don't want to use this again
Remove all project resources from your machine and external accounts. This is permanent.
Delete local project files
- Open your terminal and run the appropriate command for your operating system.
macOS
cd ~/Desktop
rm -rf sysadmin-content-pipeline
Windows
cd ~\Desktop
Remove-Item -Recurse -Force sysadmin-content-pipeline
Uninstall Python packages
- Remove the three packages this project installed by running:
pip uninstall python-dotenv requests pyyaml -y
Delete your LinkedIn Developer App
- Go to https://www.linkedin.com/developers/apps in your browser.
- Select your app from the list.
- Open the Settings tab.
- Click Delete application at the bottom of the page and confirm.
Delete your GitHub repository
- Go to your repository page on GitHub.
- Click Settings in the top navigation bar.
- Scroll to the bottom of the page to the Danger Zone section.
- Click Delete this repository and follow the confirmation prompts.
That's a Wrap!
That's a Wrap!
Nice work! You just built a production-grade content automation pipeline that turns sysadmin pain point research into voice-matched LinkedIn posts, auto-publishes them with lead magnet CTAs, and self-heals when things go wrong.
You've learned how to:
- Build a config-driven Python pipeline that generates LinkedIn posts in your authentic voice using Claude Code's CLI mode, with structured logging, dry-run testing, and exponential backoff retry logic.
- Connect LinkedIn auto-posting with a first-comment CTA strategy using OAuth 2.0, post URL logging, token expiry monitoring, and a one-command health check system.
- Publish the project to GitHub as a portfolio piece and export a shareable guide that doubles as a lead magnet for your audience.
- Added a content calendar mode that generates a full week of varied-format posts with duplicate topic detection and dry-run testing.
Ready to quiz yourself?