Mohamed Hamed | Content OS — Google Sheets + n8n + LM Studio + Claude + FLUX + DALL-E
This guide is written for a complete beginner. Each phase ends with a test so you know it works before moving on. Do not skip the tests.
What You Have vs. What You Still Need
| Platform | Status | What You Have | What's Missing |
|---|---|---|---|
| Dev.to | ✅ Ready | API key | — |
| Hashnode | ⚠ Almost | Access token | Publication ID (get below) |
| Twitter/X | ⛔ Manual only | API credentials | X API now requires $100/month — post manually using generated captions |
| ⚠ Almost | Client ID + Secret | OAuth flow (do in n8n) | |
| TikTok | ✅ Ready | Client + Secret | — |
| YouTube | ✅ Done | Already connected | — |
| Medium | ❌ Needed | — | Integration token |
| LM Studio | ✅ Local | Running on Mac | Must be running with model loaded on publish day |
| Claude API (Anthropic) | ❌ Needed | — | API key — used for viral post generation (Sonnet) |
| Replicate API | ❌ Needed | — | API token — used for FLUX.2 Pro image generation (~$0.03/image) |
| OpenAI API | ❌ Needed | — | API key — used for DALL-E 3 thumbnail generation |
| Google Sheets | ❌ Needed | — | Sheet ID + OAuth |
| n8n running | ? | Docker set up | Verify in Phase 0 |
How the System Works (Big Picture)
AI Tier Routing
Every task is routed to the right model automatically:
| Tier | Model | Used for |
|---|---|---|
| Local | LM Studio | Format article HTML → clean markdown |
| High | Claude Sonnet | 5-angle viral post generation, image prompts |
| Image | FLUX.2 Pro (Replicate) | LinkedIn post images — technical articles only (~$0.03/image) |
| Thumbnail | DALL-E 3 (OpenAI) | YouTube thumbnails — planned, not yet in workflow |
| Video | HeyGen / Kling AI | Short explainer videos, Reels — planned, not yet in workflow |
The 5 Angles System
Every article generates 5 posts — each targets a different reader mindset:
Angle 1 — Technical Depth "Here is exactly how this works under the hood"
Angle 2 — Failure Mode "Here is where this breaks in production"
Angle 3 — Career Impact "What this means for your growth as an engineer"
Angle 4 — Contrarian Take "Everyone assumes X. In production, Y happens."
Angle 5 — Accessible Hook Makes complex concepts land for a broader audienceTechnical/AI articles → 5 angles + FLUX.2 image Leadership articles → 3 angles (1, 3, 4) — no image
The Content Flow
ANY DAY — add content to queue
Write article → deploy → set Status = Pending in Articles tab
OR add raw material + your opinion to Content Sources tab
EVERY 15 MINUTES — Workflow A checks all queues automatically
Articles tab: Pending AND Publish Date <= today?
→ LM Studio formats content (free, local)
→ Publishes to Dev.to + Hashnode
→ Claude Sonnet generates 5 posts from different angles
→ FLUX.2 Pro generates post image (technical articles only, ~$0.03)
→ Saves all 5 posts + image URL to sheet
→ Status = Pending Review → emails you the drafts
Content Sources tab: Generate = Yes rows?
→ Claude Sonnet reads raw material + your opinion
→ Generates 5 LinkedIn posts
→ Saves to LinkedIn Queue tab → emails you
LinkedIn Queue tab: Approved AND Publish Date <= today?
→ Posts chosen variant to LinkedIn (text only)
→ Attaches image if one exists
→ Posts article URL as first comment
→ Status = Done
Videos tab: Status=Ready AND Publish Date <= today?
→ YouTube: reads file → uploads with metadata → URL saved to sheet
→ TikTok: Claude Haiku writes caption → emails you → you post manually
→ Status = Done / Email Sent
YOUR WORK: ~2 minutes per article
Read email → compare 5 variants → pick one
Set Status = Approved V3 (or whichever you want)
Copy tweet thread from sheet → post manually on X
Done
EVERY MONDAY — Analytics workflow
Pulls numbers from all platforms
Claude Haiku summarizes performance + patterns
Saves to Insights tabPhase 0 — Verify n8n is Running
If you haven't installed n8n yet:
mkdir -p ~/n8n/dataCreate the file ~/n8n/docker-compose.yml with this content:
version: "3.8"
services:
n8n:
image: docker.n8n.io/n8nio/n8n
restart: always
ports:
- "5678:5678"
environment:
- N8N_HOST=localhost
- N8N_PORT=5678
- N8N_PROTOCOL=http
- NODE_ENV=production
- WEBHOOK_URL=http://localhost:5678/
- GENERIC_TIMEZONE=Africa/Cairo
- N8N_ENCRYPTION_KEY=change_this_to_any_32_char_string
volumes:
- ~/.n8n:/home/node/.n8n
- ~/Videos/publish:/home/node/videosThen start it:
cd ~/n8n
docker compose up -dTEST Phase 0: Open http://localhost:5678 in your browser. You should see the n8n login/signup screen. Create your admin account if you haven't already.
✅ Passes when: You can log into n8n.
Phase 1 — Google Sheets
1A. Create the spreadsheet
- Go to sheets.google.com → click + (New spreadsheet)
- Name it:
Mohamed Hamed — Content Pipeline - At the bottom, rename "Sheet1" to Articles
- Add 4 more tabs: right-click tab → Insert sheet → name them LinkedIn, Social, Videos, Publish Log
1B. Set up the Articles tab columns
Click cell A1 and type these headers exactly (one per column):
ID | Title | Slug | Article URL | Publish Date | Status | Tags | Notes | Dev.to Status | Dev.to URL | Hashnode Status | Hashnode URL | LinkedIn Status | LinkedIn V1 | LinkedIn V2 | LinkedIn V3 | LinkedIn V4 | LinkedIn V5 | Image URL | Tweet Thread | Last Shared Date | Reshare CountStatus column values used by the workflow:
| Value | Meaning |
|---|---|
Pending | Ready to be picked up by Workflow A on publish day |
Processing | Workflow A is currently running for this row |
Pending Review | Dev.to + Hashnode published. LinkedIn drafts ready — pick one |
Approved V1 | You approved variant 1 → Workflow B will post it |
Approved V2 | You approved variant 2 → Workflow B will post it |
Approved V3 | You approved variant 3 → Workflow B will post it |
Approved V4 | You approved variant 4 → Workflow B will post it |
Approved V5 | You approved variant 5 → Workflow B will post it |
Done | Everything published |
Add a test row (row 2):
W-05 | Part 5 — AI Debugging | AI-Developer/AI-Workflow/ai-debugging-article | https://mohamedhamed.io/blog/en/AI-Developer/AI-Workflow/ai-debugging-article | 2026-04-09 | Pending | ai,debugging,productivity |1C. Add the Brand Guidelines tab
- At the bottom, add a new sheet tab named Brand Guidelines
- Add these columns:
Key|Guidelines - Add one row:
- Key:
linkedin - Guidelines: paste your writing style — tone, things you avoid, how you open posts, what topics you emphasize. Example:
code1234
Direct and specific. Never say "excited to share" or "game-changer". Write from experience, not theory. Use short sentences. Ask real questions, not "what do you think?". Reference concrete tools, numbers, or moments when possible.
- Key:
The workflow pulls this row dynamically — update it anytime without touching the workflow.
1C. Get your Sheet ID
Look at your browser URL bar — it looks like:
https://docs.google.com/spreadsheets/d/XXXXXXXXXX/edit
The Sheet ID is the long string between /d/ and /edit. Copy it — you need it later.
1D. Connect Google Sheets in n8n
- In n8n → top-right menu → Credentials → + Add Credential
- Search for
Google Sheets OAuth2 API→ select it - Name it:
Google Account - Click Connect → a browser popup opens → sign in with your Google account → allow access
- Back in n8n the credential shows a green checkmark
TEST Phase 1:
- In n8n → Workflows → + New Workflow
- Click + → search for
Google Sheets→ select it - Operation: Get Many Rows
- Document ID: paste your Sheet ID
- Sheet Name:
Articles - Click Test step
✅ Passes when: You see your test row data appear in the output panel on the right.
Phase 2 — Dev.to
2A. Save your API key in n8n
- n8n → Credentials → + Add Credential
- Search for
Header Auth→ select it - Fill in:
- Name:
Dev.to API Key - Name (header field):
api-key - Value: paste your Dev.to API key
- Name:
- Save
TEST Phase 2:
- In your test workflow → add a new HTTP Request node
- Method:
GET - URL:
https://dev.to/api/articles/me - Authentication: Header Auth → select
Dev.to API Key - Click Test step
✅ Passes when: You see a JSON list of your Dev.to articles (or an empty array [] if you have none yet).
Phase 3 — Hashnode
3A. Get your Publication ID
- Go to hashnode.com → sign in → click your avatar → Blog Dashboard
- Left sidebar → Settings → scroll down to find Publication ID
- It looks like:
64a3f2c1b8d9e7f0a1b2c3d4 - Copy it — you need it in the workflow JSON later
3B. Save your token in n8n
- n8n → Credentials → + Add Credential
- Search for
Header Auth→ select it - Fill in:
- Name:
Hashnode API Key - Name (header field):
Authorization - Value: paste your Hashnode access token (no "Bearer" prefix needed)
- Name:
- Save
TEST Phase 3:
- Add a new HTTP Request node to your test workflow
- Method:
POST - URL:
https://gql.hashnode.com - Authentication: Header Auth → select
Hashnode API Key - Body → JSON:
{
"query": "{ me { username } }"
}- Click Test step
✅ Passes when: You see { "data": { "me": { "username": "your-username" } } } in the output.
Phase 4 — Twitter/X (skipped — manual posting)
⛔ Twitter/X API now requires $100/month (Basic plan) for write access. Auto-posting is not worth it for a personal blog.
What happens instead: Claude Sonnet still generates your full tweet thread (tweet1, tweet2, tweet3) as part of the post generation step. Copy them from the
Tweet Threadcolumn in your sheet and post manually after each article goes live. Takes ~2 minutes.
No n8n setup needed for this phase. Skip to Phase 5.
Phase 5 — LinkedIn
You have Client ID + Client Secret. You need to complete the OAuth flow in n8n to get the actual access token.
5A. Configure your LinkedIn app
- Go to linkedin.com/developers/apps → click your app (or create one named
n8n Publisher) - Click Auth tab
- Under OAuth 2.0 Settings → Authorized redirect URLs → click + → add:
http://localhost:5678/rest/oauth2-credential/callback - Click Update
- Click Products tab → request access to:
- Share on LinkedIn
- Sign In with LinkedIn using OpenID Connect
5B. Connect in n8n
- n8n → Credentials → + Add Credential
- Search for
LinkedIn OAuth2 API→ select it - Name it:
LinkedIn Account - Fill in:
- Client ID: your LinkedIn Client ID
- Client Secret: your LinkedIn Client Secret
- Click Connect → a browser popup opens → sign in to LinkedIn → allow access
- Back in n8n → green checkmark
5C. Get your Person URN (needed for posting)
After connecting, run this in your test workflow:
- Add a HTTP Request node
- Method:
GET - URL:
https://api.linkedin.com/v2/userinfo - Authentication: OAuth2 → select
LinkedIn Account - Click Test step
Copy the sub field from the response. It looks like: urn:li:person:AbCdEfGhIj
Note this value — you'll need it in the workflow JSON.
TEST Phase 5:
✅ Passes when: You see your LinkedIn profile info and you have your Person URN.
LinkedIn tokens expire every 60 days. Set a recurring calendar reminder to re-authorize in n8n Credentials.
Phase 6 — LM Studio (Article Formatting — runs offline)
LM Studio handles one specific job: converting your article's HTML/CSS into clean markdown before publishing to Dev.to and Hashnode. It runs locally on your Mac — free, offline, no API key needed.
This is not caption generation. LinkedIn posts are generated by Claude Sonnet (set up in Phase 8). LM Studio only reformats the article body.
Before publish day, make sure:
- LM Studio is open on your Mac
- Go to the Server tab → click Start Server (default port 1234)
- A model is loaded (Llama 3.1 8B or Mistral 7B recommended — needs at least 8k context)
If LM Studio is offline when Workflow A runs, the workflow falls back to a simple HTML-strip and continues — you won't lose your posts, but Dev.to formatting may be rougher.
Optional — add LM Studio to Mac login items: System Settings → General → Login Items → add LM Studio so it starts automatically.
Phase 7 — Medium (Import, not API)
⚠️ Medium officially shut down its public API. No new integration tokens can be created. The n8n Medium node no longer works.
What happens instead: The workflow emails you a direct import link after each article publishes. You click the link, paste your article URL into Medium's importer, and Medium fetches the full article automatically. Takes about 30 seconds.
No setup needed — the Gmail credential from Phase 1 handles it.
To import an article manually at any time:
- Go to medium.com/p/import
- Paste your article URL (e.g.
https://mohamedhamed.io/blog/en/AI-Developer/AI-Workflow/ai-debugging-article) - Medium imports the title, body, and images — review and publish
Phase 8 — Claude API + Replicate (Post Generation + Images)
Two paid APIs power the high-quality parts of Workflow A. Set them up before building the workflow.
8A. Anthropic API (Claude Sonnet — LinkedIn post generation)
Claude Sonnet generates your 5-angle LinkedIn posts, tweet thread, and FLUX image prompt. This is the highest-value AI call in the system — quality matters here.
- Go to console.anthropic.com → API Keys → Create Key
- Name it:
n8n Content Pipeline - Copy the key — it starts with
sk-ant- - In n8n → Credentials → + Add Credential → search
Header Auth - Fill in:
- Name:
Anthropic API Key - Name (header field):
x-api-key - Value: paste your key
- Name:
- Save
Model used: claude-sonnet-4-6
Estimated cost: ~$0.002–$0.005 per article (3,000 token output)
TEST Phase 8A:
In your test workflow → HTTP Request → POST to https://api.anthropic.com/v1/messages → Header Auth → Anthropic API Key → raw body:
{"model":"claude-sonnet-4-6","max_tokens":10,"messages":[{"role":"user","content":"hi"}]}✅ Passes when: Response contains content[0].text.
8B. Replicate API (FLUX.2 Pro — LinkedIn post images)
FLUX.2 Pro generates the cover image for each technical article post. ~$0.03 per image (1:1, 1024×1024 equivalent). Only runs for technical articles — leadership articles skip this step.
- Go to replicate.com → account → API tokens → Create token
- Name it:
n8n FLUX - Copy the token — it starts with
r8_ - In n8n → Credentials → + Add Credential → search
Header Auth - Fill in:
- Name:
Replicate API Key - Name (header field):
Authorization - Value:
Token r8_YOUR_TOKEN_HERE
- Name:
- Save
Model used: black-forest-labs/flux-2-pro
Why FLUX.2 Pro: 32B parameters (vs 12B in FLUX.1 Pro), better photorealism, ~45% cheaper per image.
TEST Phase 8B:
HTTP Request → POST → https://api.replicate.com/v1/models/black-forest-labs/flux-2-pro/predictions → Header Auth → Replicate API Key → add header Prefer: wait=60 → raw body:
{"input":{"prompt":"a clean minimal technical diagram","aspect_ratio":"1:1","output_format":"webp"}}✅ Passes when: Response contains output with an image URL.
Phase 9 — Build Workflow A Node by Node
Build the workflow manually so you understand each step and can test as you go.
Workflow A does: Publish to Dev.to + Hashnode → Claude Sonnet generates 5-angle LinkedIn posts + FLUX.2 image → Save to sheet → Email you to pick one.
Create the workflow first:
n8n → Workflows → + New Workflow → click the title at the top → rename to Workflow A — Write + Publish
To add each node: click the + button on the canvas → search for the node type → select it → configure → connect it to the previous node by dragging from the output dot.
Node 1 — Schedule Trigger
Type: Schedule Trigger Purpose: Checks all queues every 15 minutes. Publishes whatever is approved and due. You control timing entirely from the Publish Date column in the sheet — no need to align with workflow trigger times.
Settings:
- Trigger interval: Minutes
- Minutes between triggers:
15
✅ Test: Click Test step → you should see one item with a timestamp. This just confirms the trigger fires.
Node 2 — Read Articles Sheet
Type: Google Sheets Purpose: Reads all rows from your Articles tab.
Settings:
- Credential: Google Account
- Operation: Get Many Rows
- Document ID: paste your Google Sheet ID
- Sheet name:
Articles
Connect from: Node 1
✅ Test: Click Test step → you should see all your sheet rows appear as items in the output panel. If you see an empty array, the sheet is empty — add the test row from Phase 1C first.
Node 3 — Filter Ready Articles
Type: Code Purpose: Keeps only rows where the Publish Date has arrived AND Status is Pending.
Settings:
- Language: JavaScript
- Paste this code:
const today = new Date().toISOString().split('T')[0];
const ready = $input.all().filter(item => {
const row = item.json;
try {
const publishDate = new Date(row['Publish Date']).toISOString().split('T')[0];
return row['Status'] === 'Pending' && publishDate <= today;
} catch(e) {
return false; // skip rows with invalid dates
}
});
if (ready.length === 0) return [];
return ready;Connect from: Node 2
✅ Test: Click Test step.
- If your test row has Status =
Pendingand a past date → you see the row in the output. - If the output is empty → change the test row's Status to
Pendingand Publish Date to today's date, then test again.
Node 4 — Loop Each Article
Type: Split In Batches Purpose: If multiple articles are ready on the same day, this processes them one at a time instead of blasting all APIs simultaneously.
Settings:
- Batch size:
1
Connect from: Node 3
This node has TWO output connectors — you'll see them stacked on the right side:
| Output | Label | What it means | Connect to |
|---|---|---|---|
| Top (output 1) | loop | "Here is the current article to process" | → Node 5 |
| Bottom (output 2) | done | "All articles have been processed" | leave unconnected |
So drag a wire from the top output only to Node 5. The bottom output stays empty.
✅ Test: Click Test step → output shows the first article row only (one item, not all of them).
Node 5 — Mark as Processing
Type: Google Sheets
Purpose: Immediately marks the article row as Processing before any publishing begins. This prevents a race condition where a crash or re-run would try to double-publish the same article.
Settings:
- Credential: Google Account
- Operation: Append or Update Row
- Document ID: your Sheet ID
- Sheet name:
Articles - Matching column:
ID - Columns to update:
ID=\{\{ $json.ID \}\}Status=Processing
- Continue on fail: ON
Connect from: Node 4 (top output)
✅ Test: Click Test step → open your sheet → the test row's Status should now say Processing.
Node 6 — Fetch RSS Feed
Type: HTTP Request
Why this node exists: Your Google Sheet only has the article title, slug, date, and tags — not the full content. The full markdown lives on your blog. This node downloads your live RSS feed (feed.xml), which contains the complete markdown of every published article. Node 7 will then extract just the one article that matches the current row.
Important: The article must have published: true in its frontmatter AND be deployed to your live site before this works. If the article is still in Draft, the RSS feed won't include it and Node 7 will produce empty content.
Settings:
- Method: GET
- URL:
https://mohamedhamed.io/feed.xml?bust=\{\{ Date.now() \}\}(the?bust=part forces a fresh download — skips any CDN cache) - Continue on fail: ON (toggle in the Settings tab)
Connect from: Node 5
✅ Test: Click Test step → the output field data should contain a large XML string starting with <?xml. Scroll through it and you should see <item> blocks with your article titles. If you get an error or empty data, the article isn't deployed yet.
Node 7 — Prepare Content
Type: Code Purpose: Extracts the article's title, content, and tags from the RSS XML. Also removes the trailing navigation section and builds the LM Studio formatting request.
Settings:
- Language: JavaScript
- Paste this code:
const rssXml = $input.first().json.data || '';
const currentRow = $('Loop Each Article').first().json;
const slug = currentRow['Slug'] || '';
const itemRegex = /<item>([\s\S]*?)<\/item>/g;
let match;
let articleContent = '';
let articleTitle = currentRow['Title'] || '';
while ((match = itemRegex.exec(rssXml)) !== null) {
const itemXml = match[1];
const linkMatch = itemXml.match(/<link>([^<]+)<\/link>/);
if (linkMatch && linkMatch[1].includes(slug)) {
const contentMatch = itemXml.match(/<content:encoded><!\[CDATA\[([\s\S]*?)\]\]><\/content:encoded>/);
if (contentMatch) articleContent = contentMatch[1];
const titleMatch = itemXml.match(/<title>([^<]+)<\/title>/);
if (titleMatch) articleTitle = titleMatch[1]
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
break;
}
}
// Remove only the last --- section if it's a Related/Next navigation block
// Uses lastIndexOf so mid-article --- dividers are never touched
const lastHr = articleContent.lastIndexOf('\n---\n');
if (lastHr !== -1) {
const trailing = articleContent.slice(lastHr);
if (/Related Reading|Related Articles|Next Article|Next in/i.test(trailing)) {
articleContent = articleContent.slice(0, lastHr);
}
}
const rawTags = (currentRow['Tags'] || '').split(',')
.map(t => t.trim()).filter(Boolean).slice(0, 4);
// Build the LM Studio request body
// JSON.stringify handles all escaping so the content passes through safely
const lmstudioRequest = JSON.stringify({
model: 'local-model',
messages: [
{
role: 'system',
content: `You are a technical content formatter. Convert articles written with custom HTML/CSS into clean markdown for Dev.to and markdown platforms.
RULES — follow exactly:
- Keep ALL content, do not skip or summarize anything
- Convert styled <div> callout boxes → markdown blockquote (> text) with a **bold** header line
- Convert stat cards (e.g. 92.6% of developers...) → **92.6%** — of developers — description on separate lines
- Convert comparison grids / side-by-side divs → markdown table
- Convert <strong> → **bold**, <em> → *italic*
- Convert <br /> or <br/> → newline
- Remove ALL HTML tags and style attributes, keep only text + markdown syntax
- Keep existing ## headings, --- dividers, and fenced code blocks exactly as-is
- Return ONLY the formatted markdown — no preamble, no explanation`
},
{
role: 'user',
content: 'Format this article:\n\n' + articleContent
}
],
temperature: 0.2,
max_tokens: 12000,
stream: false
});
return [{ json: {
...currentRow,
parsed_title: articleTitle,
parsed_content: articleContent,
parsed_tags: rawTags,
lmstudio_request: lmstudioRequest
}}];Connect from: Node 6
Important — rename your loop node: The code references $('Loop Each Article') — this must match the exact name of your Node 4. If you named it something else, update that line.
✅ Test: Click Test step → output should show parsed_title, parsed_content (full article markdown), parsed_tags, and lmstudio_request (a long JSON string). If parsed_content is empty, the slug in your sheet doesn't match the article URL.
Checkpoint 1 — Pipeline is working ✅
Nodes 1–7 form the backbone. If Node 7 output has content, the hard part is done. Everything from here is just sending that content to different platforms.
Node 8 — Format Content (LM Studio)
Type: HTTP Request Purpose: Sends the article to your local LM Studio model, which reformats the HTML-heavy content into clean, well-structured markdown suitable for Dev.to and other platforms — without removing any content.
Before you configure this node:
- Open LM Studio on your Mac
- Go to the Server tab → click Start Server (default port 1234)
- Load a model with at least 8k context window (Llama 3.1 8B or Mistral 7B work well)
Settings:
- Method: POST
- URL:
http://host.docker.internal:1234/v1/chat/completions(n8n runs in Docker —host.docker.internalis how Docker on Mac reaches your Mac's localhost) - Body Content Type: Raw
- Body:
\{\{ $json.lmstudio_request \}\} - Header:
Content-Type: application/json - Continue on fail: ON ← critical, so the workflow continues even if LM Studio is offline
Connect from: Node 7
✅ Test: Click Test step → output should contain choices[0].message.content with the reformatted markdown article. If you get a connection error, check that LM Studio's server is running and a model is loaded.
Node 9 — Read Brand Guidelines
Type: Google Sheets Purpose: Fetches your personal writing style and tone guidelines from the sheet. Claude Sonnet uses these to make all 5 LinkedIn posts sound like you, not like a generic AI post.
Settings:
- Credential: Google Account
- Operation: Get Many Rows
- Document ID: your Sheet ID
- Sheet name:
Brand Guidelines
Connect from: Node 8
✅ Test: Click Test step → output should show one row with your Key = linkedin and your Guidelines text.
Node 10 — Extract Formatted Content + Build Captions Request
Type: Code Purpose: Pulls the formatted markdown out of LM Studio. Builds the Dev.to body. Detects content type (technical vs leadership). Builds the captions request for Claude Sonnet to generate 5-angle LinkedIn posts + tweet thread + FLUX.2 image prompt.
Settings:
- Language: JavaScript
- Paste this code:
const lmResponse = $input.first().json;
const source = $('Prepare Content').first().json;
// Pull brand guidelines from the sheet (Node 9 output)
// Falls back to a sensible default if the sheet row is missing
const brandGuidelines = $('Read Brand Guidelines').all()
.find(r => r.json['Key'] === 'linkedin')?.json['Guidelines']
|| 'Direct and specific. Write from experience. Short sentences. No filler phrases.';
// Extract formatted article content from LM Studio
let formattedContent = '';
try {
formattedContent = lmResponse.choices?.[0]?.message?.content?.trim() || '';
} catch(e) {}
// Fallback: if LM Studio is offline, strip HTML manually
if (!formattedContent) {
formattedContent = source.parsed_content
.replace(/<[^>]+>/g, '')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
// Build Dev.to API body
const devtoBody = JSON.stringify({
article: {
title: source.parsed_title,
body_markdown: formattedContent,
published: true,
tags: source.parsed_tags.map(t => t.toLowerCase().replace(/\s+/g, '')).slice(0, 4),
canonical_url: source['Article URL']
}
});
// Detect content type from slug
const slug = source['Slug'] || '';
const isLeadership = /leadership|management|team|remote|mentor/i.test(slug);
const isTechnical = !isLeadership;
// Tone instruction varies by content type
const toneInstruction = isLeadership
? 'Leadership/management article. Warmer, more narrative tone. Lead with personal experience. Keep directness but reduce contrarian edge. Still no hype or corporate language.'
: 'Technical AI/engineering article. Full contrarian, failure-first, systems-thinking voice. Challenge assumptions. Ground everything in production reality.';
// Technical articles get 5 angles. Leadership gets 3 (depth, career, contrarian).
const angleCount = isTechnical ? 5 : 3;
const anglesInstruction = isTechnical
? `Write exactly 5 LinkedIn posts — one for each angle:
v1 (Technical Depth): "Here is exactly how this works under the hood" — for engineers who want to go deeper
v2 (Failure Mode): "Here is where this breaks in production" — expose the hidden failure point
v3 (Career Impact): "What this means for your growth as an engineer" — for mid-level engineers leveling up
v4 (Contrarian Take): "Everyone assumes X. In production, Y happens." — challenge the common belief
v5 (Accessible Hook): Make the complex concept land for a broader audience without dumbing it down`
: `Write exactly 3 LinkedIn posts — one for each angle:
v1 (Personal Depth): Lead with a real moment or observation from your experience
v3 (Career Impact): "What this means for your growth as an engineer or leader"
v4 (Contrarian Take): Challenge the common belief about this leadership topic`;
// Build captions request — routes to Claude Sonnet (high quality)
// Claude API format: system prompt separate, response at content[0].text
const captionsRequest = JSON.stringify({
model: 'claude-sonnet-4-6',
max_tokens: isTechnical ? 3000 : 2000,
system: `${brandGuidelines}
CONTENT TYPE: ${toneInstruction}
LINKEDIN FORMAT RULES (non-negotiable):
- First line is the HOOK — make them stop scrolling. No "I am excited to share."
- Short sentences. Frequent line breaks. Max 150 words per post.
- NO links anywhere in the post body — links kill organic reach on LinkedIn
- 3-4 specific hashtags at the very end only (e.g. #AIEngineering not #Innovation)
- End with ONE question that invites real replies — not "what do you think?"
- Include at least one punchy standalone line per post that could be quoted alone`,
messages: [
{
role: 'user',
content: `${anglesInstruction}
Each post must have a completely different hook, concrete example, and opening — not just a rewrite of the same idea.
Article title: ${source.parsed_title}
Article content (extract the insight and reframe it — do not copy verbatim):
${source.parsed_content.slice(0, 2000)}
Also write:
- A tweet thread (3 tweets under 280 chars each, URL in tweet 3 only)
- A FLUX image prompt: a clean, minimalist technical illustration for LinkedIn, no text in image, 1:1 ratio, professional tone. One sentence, specific visual concept that represents the core idea of this article.
Output ONLY valid JSON — no explanation, no markdown fences:
{
"v1": "angle 1 post",
"v2": "angle 2 post",
"v3": "angle 3 post",
${isTechnical ? '"v4": "angle 4 post",\n "v5": "angle 5 post",' : ''}
"tweet1": "first tweet",
"tweet2": "second tweet",
"tweet3": "third tweet — ${source['Article URL']}",
"image_prompt": "FLUX prompt here"
}`
}
]
});
// Build FLUX.2 Pro image request (Replicate API) — only for technical articles
const fluxRequest = isTechnical ? JSON.stringify({
input: {
prompt: '', // filled by Extract node after parsing image_prompt from Claude response
aspect_ratio: '1:1',
output_format: 'webp',
output_quality: 90
}
}) : null;
return [{ json: {
...source,
formatted_content: formattedContent,
devto_body: devtoBody,
captions_request: captionsRequest,
flux_request_template: fluxRequest,
is_technical: isTechnical,
angle_count: angleCount
}}];Connect from: Node 9
✅ Test: Click Test step → check formatted_content — it should be clean markdown with no <div> or style= anywhere. Check captions_request is present and is_technical shows the detected content type (true for AI/engineering articles, false for leadership).
Node 11 — Publish to Dev.to
Type: HTTP Request Purpose: Creates the article on Dev.to using the LM Studio-formatted content.
Settings:
- Method: POST
- URL:
https://dev.to/api/articles - Authentication: Header Auth → select
Dev.to API Key - Body Content Type: Raw
- Body:
\{\{ $json.devto_body \}\} - Header:
Content-Type: application/json - Continue on fail: ON
Connect from: Node 10
✅ Test: Click Test step → output should contain a url field like https://dev.to/yourname/article-slug. Open that URL to confirm the article looks clean and well-formatted.
Node 12 — Build Hashnode Query
Type: Code Purpose: Builds the GraphQL mutation payload for Hashnode using the LM Studio-formatted content.
Settings:
- Language: JavaScript
- Paste this code:
const item = $input.first().json;
const tags = (item.parsed_tags || []).map(t => ({
slug: t.toLowerCase().replace(/\s+/g, '-'),
name: t
}));
return [{ json: {
query: `mutation PublishPost($input: PublishPostInput!) {
publishPost(input: $input) { post { url } }
}`,
variables: {
input: {
title: item.parsed_title,
contentMarkdown: item.formatted_content || item.parsed_content,
publicationId: 'YOUR_HASHNODE_PUBLICATION_ID',
originalArticleURL: item['Article URL'],
tags: tags
}
}
}}];Replace YOUR_HASHNODE_PUBLICATION_ID with your actual ID from Phase 3A.
Connect from: Node 10 — so Hashnode gets the clean formatted version
Node 13 — Publish to Hashnode
Type: HTTP Request Purpose: Sends the GraphQL mutation to Hashnode.
Settings:
- Method: POST
- URL:
https://gql.hashnode.com - Authentication: Header Auth → select
Hashnode API Key - Body Content Type: JSON
- Body:
\{\{ JSON.stringify($json) \}\} - Continue on fail: ON
Connect from: Node 12
✅ Test: Click Test step on Node 13 → output should contain data.publishPost.post.url. Open that URL to confirm.
Node 14 — Generate Posts (Claude Sonnet)
Type: HTTP Request Purpose: Sends the captions request to Claude Sonnet to generate 5-angle LinkedIn posts + tweet thread + FLUX image prompt. Uses the HIGH tier AI for best quality output.
Settings:
- Method: POST
- URL:
https://api.anthropic.com/v1/messages - Authentication: Header Auth → select
Anthropic API Key - Headers:
anthropic-version:2023-06-01Content-Type:application/json
- Body Content Type: Raw
- Body:
\{\{ $json.captions_request \}\} - Continue on fail: ON
Connect from: Node 10
✅ Test: Click Test step → output should contain content[0].text with a JSON string containing v1 through v5 (or v1-v3 for leadership), tweet1-3, and image_prompt. Node 15 handles markdown fence stripping automatically.
Node 15 — Parse Captions
Type: Code Purpose: Extracts all 5 LinkedIn posts, tweet thread, and image prompt from the Claude Sonnet response. Handles markdown fences that Claude sometimes adds around JSON.
Settings:
- Language: JavaScript
- Paste this code:
// Claude Sonnet returns Anthropic format: content[0].text
const raw = $input.first().json?.content?.[0]?.text || '{}';
const articleData = $('Prepare Content').first().json;
// Strip markdown code fences if Claude added them
const cleaned = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '').trim();
const match = cleaned.match(/\{[\s\S]*\}/);
if (!match) {
return [{ json: { ...articleData,
linkedin_v1: '', linkedin_v2: '', linkedin_v3: '',
linkedin_v4: '', linkedin_v5: '',
tweet1: '', tweet2: '', tweet3: '',
image_prompt: '', caption_error: 'Claude did not return JSON: ' + cleaned.slice(0, 200)
}}];
}
try {
const p = JSON.parse(match[0]);
return [{ json: { ...articleData,
linkedin_v1: p.v1 || '',
linkedin_v2: p.v2 || '',
linkedin_v3: p.v3 || '',
linkedin_v4: p.v4 || '',
linkedin_v5: p.v5 || '',
tweet1: p.tweet1 || '',
tweet2: p.tweet2 || '',
tweet3: p.tweet3 || '',
image_prompt: p.image_prompt || ''
}}];
} catch(e) {
return [{ json: { ...articleData,
linkedin_v1: '', linkedin_v2: '', linkedin_v3: '',
linkedin_v4: '', linkedin_v5: '',
tweet1: '', tweet2: '', tweet3: '',
image_prompt: '', caption_error: 'JSON parse error: ' + e.message
}}];
}Connect from: Node 14
✅ Test: Click Test step → output should show linkedin_v1 through linkedin_v5 with actual text, plus image_prompt with a FLUX prompt string. Leadership articles will have v4 and v5 empty — that is expected. If you see caption_error, check your Anthropic API key credential.
Checkpoint 2 — Posts generated ✅
At this point you have up to 5 LinkedIn posts + tweet thread + an image prompt. The next two nodes generate the actual image for technical articles.
Node 16 — Generate Post Image (FLUX.2 Pro)
Type: HTTP Request Purpose: Sends the image prompt from Claude to Replicate's FLUX.2 Pro model to generate a professional cover image for the LinkedIn post. FLUX.2 Pro is 32B parameters — better photorealism, ~$0.03 per image. Only runs for technical articles — leadership articles skip this step gracefully.
Settings:
- Method: POST
- URL:
https://api.replicate.com/v1/models/black-forest-labs/flux-2-pro/predictions - Authentication: Header Auth → select
Replicate API Key - Headers:
Content-Type:application/jsonPrefer:wait=60
- Body Content Type: Raw
- Body:
{{ JSON.stringify({ input: { prompt: $json.image_prompt, aspect_ratio: "1:1", output_format: "webp", output_quality: 90 } }) }}- Continue on fail: ON
Connect from: Node 15
Prefer: wait=60tells Replicate to hold the connection open and return the result synchronously (up to 60 seconds). This avoids polling complexity. Generation typically takes ~6 seconds.
✅ Test: Click Test step → output should contain output field with an image URL (starts with https://replicate.delivery/...). If empty or error, check your Replicate API key credential.
Node 17 — Extract Image URL
Type: Code Purpose: Pulls the image URL from the Replicate response and merges it back with the article data from Node 15. Handles cases where image generation failed or was skipped.
Settings:
- Language: JavaScript
- Paste this code:
const replicateResponse = $input.first().json;
const articleData = $('Parse Captions').first().json;
// Replicate returns output as an array of URLs or a single URL
const output = replicateResponse.output;
let imageUrl = '';
if (Array.isArray(output) && output.length > 0) {
imageUrl = output[0];
} else if (typeof output === 'string' && output.startsWith('http')) {
imageUrl = output;
}
return [{ json: { ...articleData, image_url: imageUrl } }];Connect from: Node 16
✅ Test: Click Test step → output should have image_url populated for technical articles, empty string for leadership articles. All other fields from Node 15 should still be present.
Node 18 — Medium Import Reminder
Type: Gmail Purpose: Emails you a one-click import link for Medium (Medium's API is no longer available).
Settings:
- Credential: Google Account
- To:
[email protected] - Subject:
📝 Medium import ready: \{\{ $('Prepare Content').first().json.parsed_title \}\} - Message type: HTML
- Message:
<h3>Import to Medium (2 clicks)</h3>
<ol>
<li>Go to <a href="https://medium.com/p/import">medium.com/p/import</a></li>
<li>Paste: {{ $('Prepare Content').first().json['Article URL'] }}</li>
</ol>Connect from: Node 10 — runs in parallel with Dev.to, Hashnode, and captions generation
Node 19 — Save Drafts to Sheet
Type: Google Sheets
Purpose: Saves all LinkedIn posts (up to 5 variants) + tweet thread + image URL to the sheet and sets Status = Pending Review. This is what you'll read when picking your variant. Also records Dev.to and Hashnode publish results.
Settings:
- Credential: Google Account
- Operation: Append or Update Row
- Document ID: your Sheet ID
- Sheet name:
Articles - Matching column:
ID - Columns to update:
ID=\{\{ $('Prepare Content').first().json.ID \}\}Status=Pending ReviewLinkedIn V1=\{\{ $('Extract Image URL').first().json.linkedin_v1 \}\}LinkedIn V2=\{\{ $('Extract Image URL').first().json.linkedin_v2 \}\}LinkedIn V3=\{\{ $('Extract Image URL').first().json.linkedin_v3 \}\}LinkedIn V4=\{\{ $('Extract Image URL').first().json.linkedin_v4 \}\}LinkedIn V5=\{\{ $('Extract Image URL').first().json.linkedin_v5 \}\}Image URL=\{\{ $('Extract Image URL').first().json.image_url \}\}Tweet Thread=\{\{ $('Extract Image URL').first().json.tweet1 + '\n\n' + $('Extract Image URL').first().json.tweet2 + '\n\n' + $('Extract Image URL').first().json.tweet3 \}\}Dev.to Status=\{\{ $('Publish to Dev.to').first().json?.url ? 'Published' : 'Failed' \}\}Dev.to URL=\{\{ $('Publish to Dev.to').first().json?.url || '' \}\}Hashnode Status=\{\{ $('Publish to Hashnode').first().json?.data?.publishPost?.post?.url ? 'Published' : 'Failed' \}\}Hashnode URL=\{\{ $('Publish to Hashnode').first().json?.data?.publishPost?.post?.url || '' \}\}
Connect from: Node 17 (Extract Image URL — waits for image generation to complete)
✅ Test: Click Test step → open your sheet → the row should have all variant columns filled and Status = Pending Review.
Node 20 — Email Review Request
Type: Gmail Purpose: Emails you the 3 LinkedIn drafts so you can pick one without opening the spreadsheet. Also shows the Dev.to and Hashnode publish status.
Settings:
- Credential: Google Account
- To:
[email protected] - Subject:
📋 Pick a LinkedIn post: \{\{ $('Prepare Content').first().json.parsed_title \}\} - Message type: HTML
- Message:
<h2>Article Published — Pick Your LinkedIn Post</h2>
<p><b>{{ $('Prepare Content').first().json.parsed_title }}</b></p>
<h3>Platform Status</h3>
<ul>
<li>Dev.to: {{ $('Publish to Dev.to').first().json?.url || 'Failed — check execution log' }}</li>
<li>Hashnode: {{ $('Publish to Hashnode').first().json?.data?.publishPost?.post?.url || 'Failed — check execution log' }}</li>
<li>LinkedIn: Pending your approval</li>
<li>Post Image: {{ $('Extract Image URL').first().json?.image_url ? 'Generated ✅' : 'Not generated (leadership article)' }}</li>
</ul>
<h3>Next Step</h3>
<p>Open Google Sheet → set Status = <b>Approved V1</b> through <b>Approved V5</b><br />
Workflow A will pick it up within 15 minutes and post to LinkedIn.</p>
{{ $('Extract Image URL').first().json?.image_url ? '<h3>Generated Image</h3><img src="' + $('Extract Image URL').first().json.image_url + '" style="max-width:400px;border-radius:8px;" />' : '' }}
<h3>V1 — Technical Depth</h3>
<p style="background:#f0f4ff;padding:12px;white-space:pre-wrap;border-left:4px solid #4a90d9">{{ $('Extract Image URL').first().json.linkedin_v1 }}</p>
<h3>V2 — Failure Mode</h3>
<p style="background:#fff0f0;padding:12px;white-space:pre-wrap;border-left:4px solid #d94a4a">{{ $('Extract Image URL').first().json.linkedin_v2 }}</p>
<h3>V3 — Career Impact</h3>
<p style="background:#f0fff4;padding:12px;white-space:pre-wrap;border-left:4px solid #4ad96a">{{ $('Extract Image URL').first().json.linkedin_v3 }}</p>
<h3>V4 — Contrarian Take</h3>
<p style="background:#fff8f0;padding:12px;white-space:pre-wrap;border-left:4px solid #d9914a">{{ $('Extract Image URL').first().json.linkedin_v4 || '(not generated for this content type)' }}</p>
<h3>V5 — Accessible Hook</h3>
<p style="background:#fdf0ff;padding:12px;white-space:pre-wrap;border-left:4px solid #9a4ad9">{{ $('Extract Image URL').first().json.linkedin_v5 || '(not generated for this content type)' }}</p>
<h3>Tweet Thread (post manually on X)</h3>
<p style="background:#f5f5f5;padding:12px">
{{ $('Extract Image URL').first().json.tweet1 }}<br /><br />
{{ $('Extract Image URL').first().json.tweet2 }}<br /><br />
{{ $('Extract Image URL').first().json.tweet3 }}
</p>Connect from: Node 19
Node 21 — Wait Between Articles
Type: Wait Purpose: Pauses 3 minutes before the loop picks up the next article. Prevents rate limit errors when multiple articles are pending on the same day (Dev.to, LinkedIn, Hashnode all have request limits).
Settings:
- Wait amount:
3 - Unit: Minutes
Connect from: Node 20
After Node 21, the loop returns to Node 4 and processes the next pending article (if any). Connect Node 21's output back to Node 4's input to close the loop.
Final Test — Workflow A End-to-End
- In your Google Sheet, set one article row:
Status = Pending,Publish Date = today's date - Make sure that article has
published: truein its frontmatter and is deployed - In n8n, click Test Workflow at the top
- Watch each node light up green as it runs
- Check: Dev.to live, Hashnode live, email received with up to 5 LinkedIn variants
✅ Passes when: Article is live on Dev.to + Hashnode, sheet Status = Pending Review, email received with all variants and generated image (technical articles).
Activate Workflow A
After a successful full test → click the Active toggle at top right. Workflow A now runs every 15 minutes automatically.
Appendix — Workflow A JSON
The importable JSON for Workflow A is not included here — it would be outdated the moment any node changes. Build manually using the node-by-node guide above (Phase 9). Each node is fully documented with exact settings and test steps.
Phase 10 — Workflow B: LinkedIn Approval Publisher
This is the second workflow. It runs at 11am Tue/Thu, checks for rows with Status = Approved V*, and posts the chosen LinkedIn variant — with the article URL as the first comment (not in the post body).
Create the workflow:
n8n → Workflows → + New Workflow → rename to Workflow B — LinkedIn Publisher
WB Node 1 — Schedule Trigger
Type: Schedule Trigger
- Trigger interval: Minutes
- Interval:
15
(Runs every 15 minutes — picks up any Approved rows as soon as you set them)
WB Node 2 — Read Articles Sheet
Same as Workflow A Node 2. Reads all rows from the Articles tab.
WB Node 3 — Filter Approved Articles
Type: Code
const approved = $input.all().filter(item => {
const status = item.json['Status'] || '';
return ['Approved V1','Approved V2','Approved V3','Approved V4','Approved V5'].includes(status);
});
if (approved.length === 0) return [];
return approved;WB Node 4 — Loop Each Article
Same as Workflow A Node 4. Batch size: 1.
WB Node 5 — Pick Variant
Type: Code
Purpose: Looks at the Status field to know which variant was chosen, and puts that variant text into chosen_variant.
const row = $input.first().json;
const status = row['Status'] || '';
let chosenVariant = '';
if (status === 'Approved V1') chosenVariant = row['LinkedIn V1'] || '';
else if (status === 'Approved V2') chosenVariant = row['LinkedIn V2'] || '';
else if (status === 'Approved V3') chosenVariant = row['LinkedIn V3'] || '';
else if (status === 'Approved V4') chosenVariant = row['LinkedIn V4'] || '';
else if (status === 'Approved V5') chosenVariant = row['LinkedIn V5'] || '';
const imageUrl = row['Image URL'] || '';
const hasImage = imageUrl.startsWith('http');
return [{ json: { ...row, chosen_variant: chosenVariant, image_url: imageUrl, has_image: hasImage } }];WB Node 6 — Has Image? (IF)
Type: IF Purpose: Routes to image upload flow for technical articles, or straight to text post for leadership articles.
Settings:
- Condition:
\{\{ $json.has_image \}\}is true
WB Node 6a — Download Image from Replicate
(True branch only — technical articles with generated image)
Type: HTTP Request Purpose: Downloads the FLUX-generated image binary so it can be uploaded to LinkedIn's asset endpoint.
Settings:
- Method: GET
- URL:
\{\{ $json.image_url \}\} - Response Format: File
- Continue on fail: ON
WB Node 6b — Register LinkedIn Image Upload
Type: HTTP Request Purpose: Tells LinkedIn you're about to upload an image. Returns an upload URL and asset URN.
Settings:
- Method: POST
- URL:
https://api.linkedin.com/v2/assets?action=registerUpload - Authentication: OAuth2 →
LinkedIn Account - Headers:
X-Restli-Protocol-Version: 2.0.0 - Body Content Type: Raw
- Body:
{{ JSON.stringify({
"registerUploadRequest": {
"recipes": ["urn:li:digitalmediaRecipe:feedshare-image"],
"owner": "YOUR_LINKEDIN_PERSON_URN",
"serviceRelationships": [{
"relationshipType": "OWNER",
"identifier": "urn:li:userGeneratedContent"
}]
}
}) }}Replace YOUR_LINKEDIN_PERSON_URN with your URN (from Phase 5C).
WB Node 6c — Upload Image to LinkedIn
Type: HTTP Request Purpose: PUTs the image binary to the upload URL LinkedIn gave you.
Settings:
- Method: PUT
- URL:
=\{\{ $('Register LinkedIn Image Upload').first().json.value.uploadMechanism['com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest'].uploadUrl \}\} - Authentication: OAuth2 →
LinkedIn Account - Send Binary Data: ON
- Input Binary Field:
data - Continue on fail: ON
WB Node 6d — Post to LinkedIn with Image
Type: HTTP Request Purpose: Creates the LinkedIn post with the uploaded image asset.
Settings:
- Method: POST
- URL:
https://api.linkedin.com/v2/ugcPosts - Authentication: OAuth2 →
LinkedIn Account - Headers:
X-Restli-Protocol-Version: 2.0.0 - Body Content Type: Raw
- Body:
{{ JSON.stringify({
"author": "YOUR_LINKEDIN_PERSON_URN",
"lifecycleState": "PUBLISHED",
"specificContent": {
"com.linkedin.ugc.ShareContent": {
"shareCommentary": { "text": $('Pick Variant').first().json.chosen_variant },
"shareMediaCategory": "IMAGE",
"media": [{
"status": "READY",
"media": $('Register LinkedIn Image Upload').first().json.value.asset,
"title": { "text": $('Pick Variant').first().json.Title }
}]
}
},
"visibility": { "com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC" }
}) }}- Continue on fail: ON
WB Node 6e — Post to LinkedIn (Text Only)
(False branch — leadership articles with no image)
Type: LinkedIn Purpose: Posts the chosen variant — text only, no link in body.
Settings:
- Credential: LinkedIn Account
- Person:
YOUR_LINKEDIN_PERSON_URN - Text:
\{\{ $json.chosen_variant \}\} - Continue on fail: ON
WB Node 6f — Merge
Type: Merge Purpose: Joins both branches (image post + text-only post) back into a single path.
Settings:
- Mode: Combine → Merge By Position
- Connect input 0 from: WB Node 6d (image post result)
- Connect input 1 from: WB Node 6e (text-only post result)
After this node, both paths continue to WB Node 7 (First Comment).
Note: The share URN for the comment step comes from different fields depending on the path:
- Image post (ugcPosts):
$json.id→ looks likeurn:li:ugcPost:123456789- Text post (LinkedIn node):
$json.id→ looks likeurn:li:share:123456789Both work with the socialActions comment endpoint.
✅ Test: Set an article to Approved V1 → run Workflow B → check your LinkedIn profile. Technical articles should show with the generated image. Leadership articles should be text only.
WB Node 7 — Post LinkedIn First Comment
Type: HTTP Request Purpose: Immediately posts the article URL as the first comment on the LinkedIn post. This is best practice — keeps organic reach high while still making the link accessible.
The LinkedIn post response contains the share URN. For image posts (ugcPosts path) it looks like urn:li:ugcPost:123456789; for text posts it looks like urn:li:share:123456789. Both work with the socialActions comment endpoint.
Settings:
- Method: POST
- URL:
=\{\{ 'https://api.linkedin.com/v2/socialActions/' + encodeURIComponent($json.id || $('Post to LinkedIn (Text Only)').first().json.id) + '/comments' \}\} - Authentication: OAuth2 → select
LinkedIn Account - Headers:
X-Restli-Protocol-Version:2.0.0Content-Type:application/json
- Body Content Type: Raw
- Body:
{{ JSON.stringify({
"actor": "YOUR_LINKEDIN_PERSON_URN",
"message": { "text": "Full article: " + $('Pick Variant').first().json['Article URL'] }
}) }}Replace YOUR_LINKEDIN_PERSON_URN with your URN from Phase 5C.
- Continue on fail: ON
✅ Test: Click Test step → open the LinkedIn post → the first comment should be your article URL.
WB Node 8 — Update Sheet (Done)
Type: Google Sheets Purpose: Marks the row as Done and records LinkedIn status.
Settings:
- Operation: Append or Update Row
- Sheet name:
Articles - Matching column:
ID - Columns to update:
ID=\{\{ $('Pick Variant').first().json.ID \}\}Status=DoneLinkedIn Status=\{\{ $json?.id ? 'Published' : 'Failed' \}\}Last Shared Date==\{\{ new Date().toISOString().split('T')[0] \}\}Reshare Count==\{\{ ($('Pick Variant').first().json['Reshare Count'] || 0) + 1 \}\}
WB Node 9 — Email Final Summary
Type: Gmail
- Subject:
✅ Done: \{\{ $('Pick Variant').first().json.parsed_title || $('Pick Variant').first().json.Title \}\} - Message:
<h2>All done ✅</h2>
<p><b>{{ $('Pick Variant').first().json.Title }}</b></p>
<ul>
<li>Dev.to: {{ $('Pick Variant').first().json['Dev.to URL'] || 'check sheet' }}</li>
<li>Hashnode: {{ $('Pick Variant').first().json['Hashnode URL'] || 'check sheet' }}</li>
<li>LinkedIn: {{ $json?.id ? 'Posted ✅' : 'Failed ❌' }}</li>
<li>LinkedIn comment (URL): {{ $('Post LinkedIn First Comment').first().json?.id ? 'Added ✅' : 'Failed — add manually ❌' }}</li>
</ul>
<p>Tweet thread is in the Tweet Thread column — post manually on X.</p>Connect from: WB Node 8
Activate Workflow B
After testing → click Active toggle. Workflow B now checks for approved articles every 15 minutes — posts within 15 minutes of you setting the status.
Phase 11 — Workflow C: Content Sources (Standalone LinkedIn Posts)
Workflow C lets you generate LinkedIn posts from raw material — without a published article. Useful for sharing opinions, industry news, quick takes, or content you're researching.
Create the workflow:
n8n → Workflows → + New Workflow → rename to Workflow C — Content Sources
1C. Add the Content Sources tab
In your Google Sheet, add a new tab named Content Sources with these columns:
ID | Topic | Raw Material | Your Opinion | Content Type | Generate | Status | LinkedIn V1 | LinkedIn V2 | LinkedIn V3 | LinkedIn V4 | LinkedIn V5 | Image URL- Raw Material: paste notes, article excerpts, research findings, or links
- Your Opinion: your take — what you believe, what you've seen in production, what you'd push back on
- Content Type:
technicalorleadership - Generate: set to
Yesto trigger the workflow - Status:
Pending→Generated→Approved V1-V5→Done
WC Node 1 — Schedule Trigger
Every 15 minutes. Same as Workflow A.
WC Node 2 — Read Content Sources Sheet
Type: Google Sheets
- Operation: Get Many Rows
- Sheet name:
Content Sources
WC Node 3 — Filter Ready Rows
Type: Code
const ready = $input.all().filter(item =>
item.json['Generate'] === 'Yes' && item.json['Status'] === 'Pending'
);
if (ready.length === 0) return [];
return ready;WC Node 4 — Loop Each Row
Batch size: 1
WC Node 5 — Mark as Processing
Type: Google Sheets
- Operation: Append or Update Row
- Sheet:
Content Sources - Match:
ID - Set
Status=Processing
WC Node 6 — Read Brand Guidelines
Same as Workflow A Node 9.
WC Node 7 — Generate Posts (Claude Sonnet)
Type: HTTP Request
- Method: POST
- URL:
https://api.anthropic.com/v1/messages - Headers:
x-api-key,anthropic-version: 2023-06-01,content-type: application/json - Body Content Type: Raw
- Body:
\{\{ $json.captions_request \}\}
Build captions_request in a Code node before this, using the same pattern as Workflow A Node 10. The prompt:
const row = $input.first().json;
const brandGuidelines = $('Read Brand Guidelines').first().json.Guidelines || '';
const isLeadership = row['Content Type'] === 'leadership';
const angleCount = isLeadership ? 3 : 5;
const systemPrompt = `${brandGuidelines}
CONTENT TYPE: ${isLeadership ? 'Leadership/Management — warmer narrative tone' : 'Technical/AI — direct, systems-focused'}
You are generating LinkedIn posts from raw material and the author's opinion.
The author's voice and opinion must come through clearly — this is not a neutral summary.
LINKEDIN FORMAT RULES:
- No external links in the post body
- Short sentences. Frequent line breaks. No walls of text
- Strong first line — make them stop scrolling
- End with a punchy standalone insight or implicit call to action
- Max 150 words per post`;
const anglesInstruction = isLeadership
? 'Generate 3 LinkedIn posts: V1=Personal Story/Experience, V3=Career Impact, V4=Contrarian Take'
: 'Generate 5 LinkedIn posts: V1=Technical Depth, V2=Failure Mode, V3=Career Impact, V4=Contrarian Take, V5=Accessible Hook';
const captionsRequest = JSON.stringify({
model: 'claude-sonnet-4-6',
max_tokens: isLeadership ? 2000 : 3000,
system: systemPrompt,
messages: [{
role: 'user',
content: `${anglesInstruction}
Topic: ${row['Topic']}
Raw material:
${row['Raw Material']}
Author's opinion / take:
${row['Your Opinion']}
Return ONLY valid JSON with this structure:
{
"v1": "post text",
"v2": "post text",
"v3": "post text",
"v4": "post text",
"v5": "post text",
"image_prompt": "detailed FLUX prompt for a technical visual (technical articles only, otherwise empty string)"
}`
}]
});
return [{ json: { ...row, captions_request: captionsRequest, is_technical: !isLeadership } }];WC Node 8 — Parse Posts
Same pattern as Workflow A Node 15. Parses v1–v5 from Claude response.
WC Node 9 — Generate Image (FLUX.2 Pro)
Same as Workflow A Node 16. Only runs if is_technical is true. Add an IF node before this to skip for leadership content.
WC Node 10 — Save to Content Sources Sheet
Type: Google Sheets
- Operation: Append or Update Row
- Sheet:
Content Sources - Match:
ID - Set
Status=Pending Review,LinkedIn V1-V5,Image URL
WC Node 11 — Email Drafts
Same pattern as Workflow A Node 20. Shows all variants so you can pick one.
After picking → set Status = Approved V1 (or V2/V3/V4/V5). Workflow B picks it up and posts to LinkedIn within 15 minutes.
Connecting Workflow C to Workflow B: Update WB Node 2 and WB Node 3 in Workflow B to also check the Content Sources sheet. Add a second Google Sheets read node after WB Node 2, merge the two result sets in a Code node, then filter for Approved V* status across both. The Pick Variant and posting logic (WB Nodes 5–9) works identically — same columns, same status values.
Alternatively, keep them separate: when you approve a Content Sources post, copy the chosen variant text into the LinkedIn V1 column of a new row in the Articles sheet and set Status = Approved V1. Workflow B picks it up within 15 minutes.
Phase 11B — Rotating Articles
Articles you published more than 90 days ago can be shared again to reach new followers. The rotating articles logic runs weekly, checks your published articles, and suggests the ones ready to reshare.
Add Columns to Articles Tab
Add these columns to your Articles sheet:
Last Shared Date— set this when Workflow B posts to LinkedInReshare Count— increments each time the article is reshared
Workflow E — Rotating Articles Suggester
Create the workflow:
n8n → Workflows → + New Workflow → rename to Workflow E — Rotating Articles
WE Node 1: Schedule Trigger — every Monday 8am
Cron: 0 8 * * 1WE Node 2: Read Articles Sheet
WE Node 3: Filter Reshare Candidates
Type: Code
const today = new Date();
const ninetyDaysAgo = new Date(today.getTime() - 90 * 24 * 60 * 60 * 1000);
const candidates = $input.all().filter(item => {
const status = item.json['Status'];
if (status !== 'Done') return false;
const lastShared = item.json['Last Shared Date'];
if (!lastShared) {
// Never reshared — check original publish date
const publishDate = new Date(item.json['Publish Date']);
return publishDate <= ninetyDaysAgo;
}
const lastSharedDate = new Date(lastShared);
return lastSharedDate <= ninetyDaysAgo;
});
// Sort by reshare count (fewest reshares first — prioritize underexposed articles)
candidates.sort((a, b) => (a.json['Reshare Count'] || 0) - (b.json['Reshare Count'] || 0));
return candidates.slice(0, 5); // Suggest top 5WE Node 4: Generate Reshare Angles (Claude Haiku)
For each candidate, generate a fresh angle — don't repost the same text.
Type: HTTP Request (Anthropic)
- Model:
claude-haiku-4-5-20251001(cheaper, reshare is lower priority) - Prompt:
Article: {title}
Original article URL: {article_url}
Reshare count: {count}
Generate 2 fresh LinkedIn post angles for resharing this article.
The audience has grown since the original post — make each angle feel new, not like a repost.
Return JSON: { "v1": "...", "v2": "..." }WE Node 5: Email Reshare Suggestions
Sends you the candidates and their fresh angles. You decide which ones to reshare (if any).
To actually post a reshare: copy the chosen angle into the LinkedIn V1 column for that article row → set Status back to Approved V1. Workflow B will post it and update Last Shared Date.
Phase 11C — Workflow F: Analytics (Every Monday)
Workflow F runs every Monday morning, pulls performance metrics from Dev.to and LinkedIn, and asks Claude Haiku to summarize what worked and what didn't. Results go into an Insights tab in your sheet.
Create the workflow:
n8n → Workflows → + New Workflow → rename to Workflow F — Weekly Analytics
Add the Insights Tab
In your Google Sheet, add a new tab named Insights with these columns:
Week | Dev.to Views | Dev.to Reactions | Dev.to Comments | Top Dev.to Article | LinkedIn Impressions | LinkedIn Reactions | LinkedIn Comments | Top LinkedIn Post | Summary | RecommendationsWF Node 1 — Schedule Trigger
Type: Schedule Trigger
- Trigger interval: Cron
- Expression:
0 8 * * 1(8am every Monday — before you start your week)
WF Node 2 — Fetch Dev.to Stats
Type: HTTP Request
- Method: GET
- URL:
https://dev.to/api/articles/me?per_page=10&state=all - Authentication: Header Auth →
Dev.to API Key - Continue on fail: ON
WF Node 3 — Fetch LinkedIn Stats
Type: HTTP Request
- Method: GET
- URL:
https://api.linkedin.com/v2/ugcPosts?q=authors&authors=List(YOUR_LINKEDIN_PERSON_URN)&count=10 - Authentication: OAuth2 →
LinkedIn Account - Headers:
X-Restli-Protocol-Version: 2.0.0 - Continue on fail: ON
WF Node 4 — Aggregate Stats (Code)
Type: Code
const devtoArticles = $('Fetch Dev.to Stats').first().json;
const linkedinPosts = $('Fetch LinkedIn Stats').first().json;
// Dev.to — last 7 days totals
const articles = Array.isArray(devtoArticles) ? devtoArticles : [];
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const recentArticles = articles.filter(a => new Date(a.published_at) >= weekAgo);
const devtoViews = recentArticles.reduce((s, a) => s + (a.page_views_count || 0), 0);
const devtoReactions = recentArticles.reduce((s, a) => s + (a.public_reactions_count || 0), 0);
const devtoComments = recentArticles.reduce((s, a) => s + (a.comments_count || 0), 0);
const topDevto = articles.sort((a, b) => (b.page_views_count || 0) - (a.page_views_count || 0))[0];
// LinkedIn — extract from response
const liElements = linkedinPosts?.elements || [];
const liImpressions = liElements.reduce((s, p) => s + (p.totalShareStatistics?.impressionCount || 0), 0);
const liReactions = liElements.reduce((s, p) => s + (p.totalShareStatistics?.likeCount || 0), 0);
const liComments = liElements.reduce((s, p) => s + (p.totalShareStatistics?.commentCount || 0), 0);
const topLi = liElements.sort((a, b) =>
(b.totalShareStatistics?.impressionCount || 0) - (a.totalShareStatistics?.impressionCount || 0)
)[0];
const week = new Date().toISOString().split('T')[0];
return [{ json: {
week,
devto_views: devtoViews,
devto_reactions: devtoReactions,
devto_comments: devtoComments,
top_devto_article: topDevto?.title || 'n/a',
li_impressions: liImpressions,
li_reactions: liReactions,
li_comments: liComments,
top_li_post: topLi?.specificContent?.['com.linkedin.ugc.ShareContent']?.shareCommentary?.text?.slice(0, 80) || 'n/a',
raw_devto: JSON.stringify(recentArticles.map(a => ({ title: a.title, views: a.page_views_count, reactions: a.public_reactions_count }))),
raw_linkedin: JSON.stringify(liElements.map(p => ({ impressions: p.totalShareStatistics?.impressionCount, likes: p.totalShareStatistics?.likeCount })))
}}];WF Node 5 — Generate Summary (Claude Haiku)
Type: HTTP Request
- Method: POST
- URL:
https://api.anthropic.com/v1/messages - Authentication: Header Auth →
Anthropic API Key - Headers:
anthropic-version: 2023-06-01 - Body Content Type: Raw
- Body:
{{ JSON.stringify({
model: "claude-haiku-4-5-20251001",
max_tokens: 500,
messages: [{
role: "user",
content: "Weekly content performance data:\n\nDev.to: " + $json.devto_views + " views, " + $json.devto_reactions + " reactions, " + $json.devto_comments + " comments. Top article: " + $json.top_devto_article + "\n\nLinkedIn: " + $json.li_impressions + " impressions, " + $json.li_reactions + " reactions, " + $json.li_comments + " comments. Top post: " + $json.top_li_post + "\n\nWrite a 3-sentence summary of what worked this week and 2 concrete recommendations for next week. Be specific and direct — no filler."
}]
}) }}- Continue on fail: ON
WF Node 6 — Save to Insights Sheet
Type: Google Sheets
- Operation: Append Row
- Sheet:
Insights - Map columns:
Week=\{\{ $('Aggregate Stats').first().json.week \}\}Dev.to Views=\{\{ $('Aggregate Stats').first().json.devto_views \}\}Dev.to Reactions=\{\{ $('Aggregate Stats').first().json.devto_reactions \}\}Dev.to Comments=\{\{ $('Aggregate Stats').first().json.devto_comments \}\}Top Dev.to Article=\{\{ $('Aggregate Stats').first().json.top_devto_article \}\}LinkedIn Impressions=\{\{ $('Aggregate Stats').first().json.li_impressions \}\}LinkedIn Reactions=\{\{ $('Aggregate Stats').first().json.li_reactions \}\}LinkedIn Comments=\{\{ $('Aggregate Stats').first().json.li_comments \}\}Top LinkedIn Post=\{\{ $('Aggregate Stats').first().json.top_li_post \}\}Summary=\{\{ $input.first().json?.content?.[0]?.text || 'Summary unavailable' \}\}
WF Node 7 — Email Weekly Report
Type: Gmail
- Subject:
📊 Weekly Analytics — \{\{ $('Aggregate Stats').first().json.week \}\} - Message:
<h2>Week of {{ $('Aggregate Stats').first().json.week }}</h2>
<h3>Dev.to</h3>
<ul>
<li>Views: {{ $('Aggregate Stats').first().json.devto_views }}</li>
<li>Reactions: {{ $('Aggregate Stats').first().json.devto_reactions }}</li>
<li>Comments: {{ $('Aggregate Stats').first().json.devto_comments }}</li>
<li>Top article: {{ $('Aggregate Stats').first().json.top_devto_article }}</li>
</ul>
<h3>LinkedIn</h3>
<ul>
<li>Impressions: {{ $('Aggregate Stats').first().json.li_impressions }}</li>
<li>Reactions: {{ $('Aggregate Stats').first().json.li_reactions }}</li>
<li>Comments: {{ $('Aggregate Stats').first().json.li_comments }}</li>
<li>Top post: {{ $('Aggregate Stats').first().json.top_li_post }}</li>
</ul>
<h3>Claude Haiku Summary</h3>
<p>{{ $('Generate Summary (Claude Haiku)').first().json?.content?.[0]?.text }}</p>Activate after testing. Runs every Monday at 8am — results saved to Insights tab and emailed.
Phase 12 — Videos Tab Setup
Before building Workflow G, set up the Videos tab properly.
12A. Videos Tab Columns
In your Google Sheet → Videos tab, set these headers exactly:
ID | Title | Description | Tags | Platform | File Path | Thumbnail Path | Category | Privacy | Status | Publish Date | Notes | YouTube URL | TikTok Caption | Published Date| Column | What to put |
|---|---|
ID | e.g. V-01, V-02 |
Title | Video title |
Description | YouTube description — leave empty and Workflow G generates it |
Tags | Comma-separated, e.g. AI,Claude,n8n |
Platform | YouTube or TikTok |
File Path | /home/node/videos/your-file.mp4 (Docker path) |
Thumbnail Path | /home/node/videos/your-thumb.jpg — optional |
Category | YouTube category ID — 28 = Science & Technology, 27 = Education |
Privacy | public, private, or unlisted |
Status | Ready → workflow picks it up |
Publish Date | Date to publish — workflow waits until this date |
Notes | Context for Claude to write the description |
YouTube URL | Filled by workflow after upload |
TikTok Caption | Filled by workflow — copy and paste manually into TikTok |
Published Date | Filled by workflow |
12B. File Path Mapping
Move video files to ~/Videos/publish/ on your Mac. Inside Docker, this maps to /home/node/videos/.
Example: Mac path ~/Videos/publish/my-video.mp4 → sheet value /home/node/videos/my-video.mp4
12C. YouTube Category IDs (common ones)
| ID | Category |
|---|---|
28 | Science & Technology |
27 | Education |
24 | Entertainment |
22 | People & Blogs |
26 | Howto & Style |
For AI/engineering content use 28.
Phase 13 — TikTok (Email Notification — No API Upload)
TikTok's upload API requires a developer application approval that can take weeks and isn't guaranteed for personal accounts. Workflow G handles TikTok by generating the caption and emailing it to you — upload takes ~90 seconds manually.
For TikTok rows in your Videos tab:
Platform=TikTokNotes= brief description of the video (Claude uses this to write the caption)Status=Ready
The workflow generates a short punchy caption in your brand voice, emails it with the file path, and marks the row as Email Sent.
Workflow G — Video Publisher (every 15 min)
Handles YouTube uploads automatically and TikTok caption generation + email. Picks up any row in the Videos tab with Status = Ready and Publish Date ≤ today.
Create the workflow:
n8n → Workflows → + New Workflow → rename to Workflow G — Video Publisher
WG Node 1 — Schedule Trigger
Type: Schedule Trigger
- Interval: Minutes →
15
WG Node 2 — Read Videos Sheet
Type: Google Sheets
- Operation: Get Many Rows
- Document ID: your Sheet ID
- Sheet name:
Videos
WG Node 3 — Filter Ready Videos
Type: Code
const today = new Date().toISOString().split('T')[0];
const ready = $input.all().filter(item => {
const status = item.json['Status'] || '';
const publishDate = item.json['Publish Date'] || '';
return status === 'Ready' && publishDate <= today;
});
if (ready.length === 0) return [];
return ready;WG Node 4 — Loop Each Video
Type: Split In Batches
- Batch size:
1
WG Node 5 — Mark as Processing
Type: Google Sheets
- Operation: Append or Update Row
- Sheet:
Videos - Match column:
ID - Set
Status=Processing
WG Node 6 — Generate Description (IF Empty)
Type: IF
- Condition:
\{\{ $json.Description \}\}is empty
True path → WG Node 6a (Claude generates description) False path → WG Node 6b (use existing — pass through)
WG Node 6a — Write Description (Claude Haiku)
Type: HTTP Request
- Method: POST
- URL:
https://api.anthropic.com/v1/messages - Authentication: Header Auth →
Anthropic API Key - Headers:
anthropic-version: 2023-06-01 - Body Content Type: Raw
- Body:
{{ JSON.stringify({
"model": "claude-haiku-4-5-20251001",
"max_tokens": 600,
"messages": [{
"role": "user",
"content": "Write a YouTube video description for an AI/engineering creator.\n\nVideo title: " + $json.Title + "\nNotes: " + ($json.Notes || 'No notes provided') + "\n\nRequirements:\n- 150-200 words\n- First 2 lines are the hook (visible before 'Show more')\n- Include what viewers will learn\n- End with: 'Subscribe for weekly AI engineering content'\n- No clickbait, no hype\n\nAlso write a TikTok caption: punchy, max 150 chars, no hashtags in the sentence (add 3 relevant hashtags on a new line at the end).\n\nReturn JSON only: {\"description\": \"...\", \"tiktok_caption\": \"...\"}"
}]
}) }}- Continue on fail: ON
WG Node 6b — Parse Generated Description
Type: Code Purpose: Extracts description and TikTok caption from Claude response, or falls back to existing values.
const row = $('Loop Each Video').first().json;
let description = row['Description'] || '';
let tiktokCaption = row['TikTok Caption'] || '';
// Try to parse Claude response (only present if Node 6a ran)
try {
const claudeText = $input.first().json?.content?.[0]?.text || '';
if (claudeText) {
const match = claudeText.match(/\{[\s\S]*\}/);
if (match) {
const p = JSON.parse(match[0]);
description = p.description || description;
tiktokCaption = p.tiktok_caption || tiktokCaption;
}
}
} catch(e) {}
return [{ json: { ...row, Description: description, tiktok_caption: tiktokCaption } }];Connect: both WG Node 6a output AND WG Node 6 false output feed into this node (set as a Merge node if needed, or connect WG Node 6 false branch directly to WG Node 6b).
WG Node 7 — Platform Router (IF)
Type: IF
- Condition:
\{\{ $json.Platform \}\}equalsYouTube
True path → YouTube upload flow (WG Nodes 8–10) False path → TikTok email flow (WG Node 11)
WG Node 8 — Read Video File
(YouTube path only)
Type: Read/Write Files from Disk Purpose: Reads the video binary from the Docker-mounted volume.
Settings:
- Operation: Read File
- File Path:
\{\{ $json['File Path'] \}\} - Put Output in Field:
video_data
This node reads from
/home/node/videos/— the path you set in the sheet. Make sure the file is in~/Videos/publish/on your Mac before the workflow runs.
WG Node 9 — Upload to YouTube
Type: YouTube Purpose: Uploads the video with all metadata.
Settings:
- Credential: YouTube account
- Operation: Upload
- Title:
\{\{ $json.Title \}\} - Description:
\{\{ $json.Description \}\} - Tags:
=\{\{ $json.Tags.split(',').map(t => t.trim()) \}\} - Category ID:
\{\{ $json.Category || '28' \}\} - Privacy Status:
\{\{ $json.Privacy || 'public' \}\} - Input Binary Field:
video_data - Continue on fail: ON
WG Node 10 — Update Sheet (YouTube Done)
Type: Google Sheets
- Operation: Append or Update Row
- Sheet:
Videos - Match:
ID - Columns:
Status=DoneYouTube URL==\{\{ 'https://www.youtube.com/watch?v=' + $json.id \}\}Published Date==\{\{ new Date().toISOString().split('T')[0] \}\}TikTok Caption==\{\{ $('Parse Generated Description').first().json.tiktok_caption \}\}
WG Node 11 — TikTok Email Notification
(False path — TikTok platform)
Type: Gmail
- To:
[email protected] - Subject:
📱 TikTok ready: \{\{ $json.Title \}\} - Message:
<h2>TikTok Upload Ready</h2>
<p><b>{{ $json.Title }}</b></p>
<h3>Caption (copy this)</h3>
<p style="background:#f5f5f5;padding:12px;font-size:16px">{{ $json.tiktok_caption }}</p>
<h3>File</h3>
<p>{{ $json['File Path'] }}</p>
<p style="color:#888">Copy from: ~/Videos/publish/{{ $json['File Path'].split('/').pop() }}</p>
<p>Upload in the TikTok app → New post → select file → paste caption → post.</p>WG Node 12 — Update Sheet (TikTok Notified)
Type: Google Sheets
- Operation: Append or Update Row
- Sheet:
Videos - Match:
ID - Columns:
Status=Email SentTikTok Caption==\{\{ $('Parse Generated Description').first().json.tiktok_caption \}\}Published Date==\{\{ new Date().toISOString().split('T')[0] \}\}
WG Node 13 — Email Confirmation (YouTube)
Type: Gmail
- Connect from: WG Node 10
- Subject:
✅ YouTube live: \{\{ $('Loop Each Video').first().json.Title \}\} - Message:
<h2>Video Published ✅</h2>
<p><b>{{ $('Loop Each Video').first().json.Title }}</b></p>
<ul>
<li>YouTube URL: <a href="{{ $('Update Sheet (YouTube Done)').first().json['YouTube URL'] }}">{{ $('Update Sheet (YouTube Done)').first().json['YouTube URL'] }}</a></li>
<li>Privacy: {{ $('Loop Each Video').first().json.Privacy || 'public' }}</li>
<li>Published: {{ new Date().toISOString().split('T')[0] }}</li>
</ul>
<h3>TikTok Caption (post manually)</h3>
<p style="background:#f5f5f5;padding:12px">{{ $('Parse Generated Description').first().json.tiktok_caption }}</p>WG Node 14 — Wait Between Videos
Type: Wait
- Amount:
2 - Unit: Minutes
Connect from: WG Node 13 and WG Node 12 (both paths) Connect output back to: WG Node 4 (closes the loop)
Final Test — Workflow G End-to-End
- Add a test row to Videos tab:
ID=V-TESTTitle=Test VideoPlatform=YouTubeFile Path=/home/node/videos/test.mp4(put a small test mp4 in~/Videos/publish/)Category=28Privacy=privateStatus=ReadyPublish Date= today's date
- In n8n → click Test Workflow
- Watch nodes run — video should appear in your YouTube Studio as a private upload
- Check sheet row: Status =
Done, YouTube URL filled
✅ Passes when: Video appears in YouTube Studio and sheet is updated.
Activate Workflow G
After a successful test → click Active toggle. Workflow G runs every 15 minutes — picks up any Ready video on its publish date.
Workflow D — LinkedIn Token Alert
LinkedIn OAuth2 tokens expire every 60 days. This small workflow runs every Monday, tests the LinkedIn connection, and emails you if re-authorization is needed — before your posts start failing silently.
Import
In n8n → Workflows → New → ⋮ → Import from JSON → paste:
{
"name": "Workflow D — LinkedIn Token Alert",
"nodes": [
{
"parameters": {
"rule": { "interval": [{ "field": "weeks", "weeksInterval": 1 }] }
},
"id": "li-schedule",
"name": "Every Monday",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1,
"position": [100, 300]
},
{
"parameters": {
"method": "GET",
"url": "https://api.linkedin.com/v2/userinfo",
"authentication": "oAuth2",
"options": {}
},
"id": "li-test",
"name": "Test LinkedIn Connection",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [300, 300],
"continueOnFail": true,
"credentials": { "oAuth2Api": { "id": "6", "name": "LinkedIn Account" } }
},
{
"parameters": {
"conditions": {
"conditions": [{
"leftValue": "={{ $json.error || $json.status }}",
"operator": { "type": "string", "operation": "exists" },
"rightValue": ""
}],
"combinator": "or"
}
},
"id": "li-check",
"name": "Token Expired?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [500, 300]
},
{
"parameters": {
"sendTo": "[email protected]",
"subject": "⚠️ LinkedIn token expired — action needed",
"emailType": "html",
"message": "<h2>LinkedIn token needs re-authorization</h2><p>Your LinkedIn OAuth2 token has expired. Posts will fail until you fix this.</p><p><strong>Fix (2 minutes):</strong></p><ol><li>Open <a href='http://localhost:5678'>localhost:5678</a></li><li>Top menu → Credentials → LinkedIn Account</li><li>Click the credential → click <strong>Reconnect</strong></li><li>Sign in to LinkedIn → authorize</li></ol><p>This needs to be done every 60 days.</p>",
"options": {}
},
"id": "li-alert",
"name": "Send Alert Email",
"type": "n8n-nodes-base.gmail",
"typeVersion": 2,
"position": [700, 200],
"credentials": { "gmailOAuth2": { "id": "1", "name": "Google Account" } }
},
{
"parameters": {},
"id": "li-ok",
"name": "Token OK — Do Nothing",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [700, 400]
}
],
"connections": {
"Every Monday": { "main": [[{ "node": "Test LinkedIn Connection", "type": "main", "index": 0 }]] },
"Test LinkedIn Connection": { "main": [[{ "node": "Token Expired?", "type": "main", "index": 0 }]] },
"Token Expired?": {
"main": [
[{ "node": "Send Alert Email", "type": "main", "index": 0 }],
[{ "node": "Token OK — Do Nothing", "type": "main", "index": 0 }]
]
}
}
}Activate this workflow after importing. You will receive an email if — and only if — re-authorization is needed.
Weekly Routine
SUNDAY (all your work for the week — 2-3 hours)
Write articles → set published: true in frontmatter → git push
Vercel deploys automatically (articles live in ~2 minutes)
Google Sheet → find each article row → set Status = Pending
(Publish Date already filled from the CSV import)
ON PUBLISH DATE — Workflow A picks it up automatically within 15 min (0 min)
Publishes to Dev.to + Hashnode
Claude Sonnet generates 5 LinkedIn posts from different angles
FLUX generates a post image (technical articles only)
Emails you the drafts with inline image preview
YOUR WORK: ~2 minutes
Open email → read up to 5 LinkedIn variants
Pick the angle that fits the week's narrative
Open Google Sheet → set Status = Approved V1 (or V2/V3/V4/V5)
Copy tweet thread from sheet → post on X manually
WITHIN 15 MINUTES — Workflow B runs automatically (0 min)
Posts chosen LinkedIn variant
Attaches image for technical articles
Adds article URL as first comment on LinkedIn post
Emails final summary with all links
Total manual work per article: ~2 min (picking a variant + posting tweet)Troubleshooting
| Problem | Where to look | Fix |
|---|---|---|
| Workflow A not running | n8n → Workflow A → check Active toggle | Make sure toggle is ON |
| Article stuck at Processing | Google Sheet Status column | Workflow crashed mid-run — manually set Status back to Pending and re-run |
| Article not picked up | Google Sheet Articles tab | Status must be exactly Pending, Publish Date must be today or past |
| Dev.to publish failed | n8n → execution → Publish to Dev.to node | Check API key credential; check Dev.to Status column |
| Hashnode publish failed | Build Hashnode Query node | Verify Publication ID is correct (no spaces); check Hashnode Status column |
Captions show caption_error | Node 15 (Parse Captions) output | Claude API key missing or rate limited — check Anthropic API key credential |
| LinkedIn variants empty | Node 15 output | Same as above — verify API key and check Claude usage dashboard |
| Workflow B not posting | Check Status column | Must be exactly Approved V1 through Approved V5 |
| LinkedIn first comment failed | WB Node 7 | Check LinkedIn URN is set correctly; add comment manually if needed |
| LinkedIn token expired | Workflow D sends email alert automatically | Open localhost:5678 → Credentials → LinkedIn Account → Reconnect |
| Twitter posting | Manual step | Copy tweet thread from Tweet Thread column in sheet → post on X |
| RSS feed empty | Fetch RSS Feed node | Article must have published: true in frontmatter AND be deployed |
| Email not sending | Email Review Request node | Re-authorize Google Account credential |
| Brand guidelines not loading | Node 9 (Read Brand Guidelines) | Check sheet has a Brand Guidelines tab with Key = linkedin row |
n8n Useful Commands
# Stop n8n
docker compose -f ~/n8n/docker-compose.yml down
# Start n8n
docker compose -f ~/n8n/docker-compose.yml up -d
# View live logs
docker compose -f ~/n8n/docker-compose.yml logs -f
# Update n8n to latest version
docker compose -f ~/n8n/docker-compose.yml pull
docker compose -f ~/n8n/docker-compose.yml up -dAuto-start on Mac login: Docker Desktop → Settings → General → enable "Start Docker Desktop when you log in"