Skip to main content

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

PlatformStatusWhat You HaveWhat's Missing
Dev.to✅ ReadyAPI key
Hashnode⚠ AlmostAccess tokenPublication ID (get below)
Twitter/X⛔ Manual onlyAPI credentialsX API now requires $100/month — post manually using generated captions
LinkedIn⚠ AlmostClient ID + SecretOAuth flow (do in n8n)
TikTok✅ ReadyClient + Secret
YouTube✅ DoneAlready connected
Medium❌ NeededIntegration token
LM Studio✅ LocalRunning on MacMust be running with model loaded on publish day
Claude API (Anthropic)❌ NeededAPI key — used for viral post generation (Sonnet)
Replicate API❌ NeededAPI token — used for FLUX.2 Pro image generation (~$0.03/image)
OpenAI API❌ NeededAPI key — used for DALL-E 3 thumbnail generation
Google Sheets❌ NeededSheet ID + OAuth
n8n running?Docker set upVerify in Phase 0

How the System Works (Big Picture)

AI Tier Routing

Every task is routed to the right model automatically:

TierModelUsed for
LocalLM StudioFormat article HTML → clean markdown
HighClaude Sonnet5-angle viral post generation, image prompts
ImageFLUX.2 Pro (Replicate)LinkedIn post images — technical articles only (~$0.03/image)
ThumbnailDALL-E 3 (OpenAI)YouTube thumbnails — planned, not yet in workflow
VideoHeyGen / Kling AIShort explainer videos, Reels — planned, not yet in workflow

The 5 Angles System

Every article generates 5 posts — each targets a different reader mindset:

code
12345
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 audience

Technical/AI articles → 5 angles + FLUX.2 image Leadership articles → 3 angles (1, 3, 4) — no image

The Content Flow

code
12345678910111213141516171819202122232425262728293031323334353637383940
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 tab

Phase 0 — Verify n8n is Running

If you haven't installed n8n yet:

bash
mkdir -p ~/n8n/data

Create the file ~/n8n/docker-compose.yml with this content:

yaml
123456789101112131415161718
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/videos

Then start it:

bash
cd ~/n8n
docker compose up -d

TEST 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

  1. Go to sheets.google.com → click + (New spreadsheet)
  2. Name it: Mohamed Hamed — Content Pipeline
  3. At the bottom, rename "Sheet1" to Articles
  4. 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):

code
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 Count

Status column values used by the workflow:

ValueMeaning
PendingReady to be picked up by Workflow A on publish day
ProcessingWorkflow A is currently running for this row
Pending ReviewDev.to + Hashnode published. LinkedIn drafts ready — pick one
Approved V1You approved variant 1 → Workflow B will post it
Approved V2You approved variant 2 → Workflow B will post it
Approved V3You approved variant 3 → Workflow B will post it
Approved V4You approved variant 4 → Workflow B will post it
Approved V5You approved variant 5 → Workflow B will post it
DoneEverything published

Add a test row (row 2):

code
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

  1. At the bottom, add a new sheet tab named Brand Guidelines
  2. Add these columns: Key | Guidelines
  3. Add one row:
    • Key: linkedin
    • Guidelines: paste your writing style — tone, things you avoid, how you open posts, what topics you emphasize. Example:
      code
      1234
      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.

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

  1. In n8n → top-right menu → Credentials+ Add Credential
  2. Search for Google Sheets OAuth2 API → select it
  3. Name it: Google Account
  4. Click Connect → a browser popup opens → sign in with your Google account → allow access
  5. Back in n8n the credential shows a green checkmark

TEST Phase 1:

  1. In n8n → Workflows → + New Workflow
  2. Click + → search for Google Sheets → select it
  3. Operation: Get Many Rows
  4. Document ID: paste your Sheet ID
  5. Sheet Name: Articles
  6. 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

  1. n8n → Credentials → + Add Credential
  2. Search for Header Auth → select it
  3. Fill in:
    • Name: Dev.to API Key
    • Name (header field): api-key
    • Value: paste your Dev.to API key
  4. Save

TEST Phase 2:

  1. In your test workflow → add a new HTTP Request node
  2. Method: GET
  3. URL: https://dev.to/api/articles/me
  4. Authentication: Header Auth → select Dev.to API Key
  5. 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

  1. Go to hashnode.com → sign in → click your avatar → Blog Dashboard
  2. Left sidebar → Settings → scroll down to find Publication ID
  3. It looks like: 64a3f2c1b8d9e7f0a1b2c3d4
  4. Copy it — you need it in the workflow JSON later

3B. Save your token in n8n

  1. n8n → Credentials → + Add Credential
  2. Search for Header Auth → select it
  3. Fill in:
    • Name: Hashnode API Key
    • Name (header field): Authorization
    • Value: paste your Hashnode access token (no "Bearer" prefix needed)
  4. Save

TEST Phase 3:

  1. Add a new HTTP Request node to your test workflow
  2. Method: POST
  3. URL: https://gql.hashnode.com
  4. Authentication: Header Auth → select Hashnode API Key
  5. Body → JSON:
json
123
{
  "query": "{ me { username } }"
}
  1. 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 Thread column 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

  1. Go to linkedin.com/developers/apps → click your app (or create one named n8n Publisher)
  2. Click Auth tab
  3. Under OAuth 2.0 SettingsAuthorized redirect URLs → click + → add: http://localhost:5678/rest/oauth2-credential/callback
  4. Click Update
  5. Click Products tab → request access to:
    • Share on LinkedIn
    • Sign In with LinkedIn using OpenID Connect

5B. Connect in n8n

  1. n8n → Credentials → + Add Credential
  2. Search for LinkedIn OAuth2 API → select it
  3. Name it: LinkedIn Account
  4. Fill in:
    • Client ID: your LinkedIn Client ID
    • Client Secret: your LinkedIn Client Secret
  5. Click Connect → a browser popup opens → sign in to LinkedIn → allow access
  6. Back in n8n → green checkmark

5C. Get your Person URN (needed for posting)

After connecting, run this in your test workflow:

  1. Add a HTTP Request node
  2. Method: GET
  3. URL: https://api.linkedin.com/v2/userinfo
  4. Authentication: OAuth2 → select LinkedIn Account
  5. 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:

  1. LM Studio is open on your Mac
  2. Go to the Server tab → click Start Server (default port 1234)
  3. 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:

  1. Go to medium.com/p/import
  2. Paste your article URL (e.g. https://mohamedhamed.io/blog/en/AI-Developer/AI-Workflow/ai-debugging-article)
  3. 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.

  1. Go to console.anthropic.com → API KeysCreate Key
  2. Name it: n8n Content Pipeline
  3. Copy the key — it starts with sk-ant-
  4. In n8n → Credentials → + Add Credential → search Header Auth
  5. Fill in:
    • Name: Anthropic API Key
    • Name (header field): x-api-key
    • Value: paste your key
  6. 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:

json
{"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.

  1. Go to replicate.com → account → API tokensCreate token
  2. Name it: n8n FLUX
  3. Copy the token — it starts with r8_
  4. In n8n → Credentials → + Add Credential → search Header Auth
  5. Fill in:
    • Name: Replicate API Key
    • Name (header field): Authorization
    • Value: Token r8_YOUR_TOKEN_HERE
  6. 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:

json
{"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:
javascript
123456789101112
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 = Pending and a past date → you see the row in the output.
  • If the output is empty → change the test row's Status to Pending and 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:

OutputLabelWhat it meansConnect 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:
javascript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
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(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/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:

  1. Open LM Studio on your Mac
  2. Go to the Server tab → click Start Server (default port 1234)
  3. 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.internal is 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:
javascript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
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:
javascript
1234567891011121314151617181920
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-01
    • Content-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:
javascript
12345678910111213141516171819202122232425262728293031323334353637
// 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/json
    • Prefer: wait=60
  • Body Content Type: Raw
  • Body:
code
{{ 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=60 tells 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:
javascript
12345678910111213
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:
html
12345
<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 Review
    • LinkedIn 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:
html
1234567891011121314151617181920212223242526272829303132333435363738
<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

  1. In your Google Sheet, set one article row: Status = Pending, Publish Date = today's date
  2. Make sure that article has published: true in its frontmatter and is deployed
  3. In n8n, click Test Workflow at the top
  4. Watch each node light up green as it runs
  5. 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

javascript
123456
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.

javascript
1234567891011121314
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: OAuth2LinkedIn Account
  • Headers: X-Restli-Protocol-Version: 2.0.0
  • Body Content Type: Raw
  • Body:
code
12345678910
{{ 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: OAuth2LinkedIn 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: OAuth2LinkedIn Account
  • Headers: X-Restli-Protocol-Version: 2.0.0
  • Body Content Type: Raw
  • Body:
code
12345678910111213141516
{{ 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: CombineMerge 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 like urn:li:ugcPost:123456789
  • Text post (LinkedIn node): $json.id → looks like urn:li:share:123456789 Both 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.0
    • Content-Type: application/json
  • Body Content Type: Raw
  • Body:
code
1234
{{ 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 = Done
    • LinkedIn 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:
html
123456789
<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:

code
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: technical or leadership
  • Generate: set to Yes to trigger the workflow
  • Status: PendingGeneratedApproved V1-V5Done

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

javascript
12345
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:

javascript
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
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 LinkedIn
  • Reshare 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

code
Cron: 0 8 * * 1

WE Node 2: Read Articles Sheet

WE Node 3: Filter Reshare Candidates

Type: Code

javascript
12345678910111213141516171819202122
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 5

WE 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:
code
1234567
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:

code
Week | Dev.to Views | Dev.to Reactions | Dev.to Comments | Top Dev.to Article | LinkedIn Impressions | LinkedIn Reactions | LinkedIn Comments | Top LinkedIn Post | Summary | Recommendations

WF 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 AuthDev.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: OAuth2LinkedIn Account
  • Headers: X-Restli-Protocol-Version: 2.0.0
  • Continue on fail: ON

WF Node 4 — Aggregate Stats (Code)

Type: Code

javascript
12345678910111213141516171819202122232425262728293031323334353637
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 AuthAnthropic API Key
  • Headers: anthropic-version: 2023-06-01
  • Body Content Type: Raw
  • Body:
code
12345678
{{ 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:
html
1234567891011121314151617181920
<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:

code
ID | Title | Description | Tags | Platform | File Path | Thumbnail Path | Category | Privacy | Status | Publish Date | Notes | YouTube URL | TikTok Caption | Published Date
ColumnWhat to put
IDe.g. V-01, V-02
TitleVideo title
DescriptionYouTube description — leave empty and Workflow G generates it
TagsComma-separated, e.g. AI,Claude,n8n
PlatformYouTube or TikTok
File Path/home/node/videos/your-file.mp4 (Docker path)
Thumbnail Path/home/node/videos/your-thumb.jpg — optional
CategoryYouTube category ID — 28 = Science & Technology, 27 = Education
Privacypublic, private, or unlisted
StatusReady → workflow picks it up
Publish DateDate to publish — workflow waits until this date
NotesContext for Claude to write the description
YouTube URLFilled by workflow after upload
TikTok CaptionFilled by workflow — copy and paste manually into TikTok
Published DateFilled 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)

IDCategory
28Science & Technology
27Education
24Entertainment
22People & Blogs
26Howto & 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 = TikTok
  • Notes = 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: Minutes15

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

javascript
12345678
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 AuthAnthropic API Key
  • Headers: anthropic-version: 2023-06-01
  • Body Content Type: Raw
  • Body:
code
12345678
{{ 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.

javascript
123456789101112131415161718
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 \}\} equals YouTube

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 = Done
    • YouTube 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

html
1234567891011
<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 Sent
    • TikTok 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:
html
123456789
<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

  1. Add a test row to Videos tab:
    • ID = V-TEST
    • Title = Test Video
    • Platform = YouTube
    • File Path = /home/node/videos/test.mp4 (put a small test mp4 in ~/Videos/publish/)
    • Category = 28
    • Privacy = private
    • Status = Ready
    • Publish Date = today's date
  2. In n8n → click Test Workflow
  3. Watch nodes run — video should appear in your YouTube Studio as a private upload
  4. 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:

json
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980
{
  "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

code
12345678910111213141516171819202122232425
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

ProblemWhere to lookFix
Workflow A not runningn8n → Workflow A → check Active toggleMake sure toggle is ON
Article stuck at ProcessingGoogle Sheet Status columnWorkflow crashed mid-run — manually set Status back to Pending and re-run
Article not picked upGoogle Sheet Articles tabStatus must be exactly Pending, Publish Date must be today or past
Dev.to publish failedn8n → execution → Publish to Dev.to nodeCheck API key credential; check Dev.to Status column
Hashnode publish failedBuild Hashnode Query nodeVerify Publication ID is correct (no spaces); check Hashnode Status column
Captions show caption_errorNode 15 (Parse Captions) outputClaude API key missing or rate limited — check Anthropic API key credential
LinkedIn variants emptyNode 15 outputSame as above — verify API key and check Claude usage dashboard
Workflow B not postingCheck Status columnMust be exactly Approved V1 through Approved V5
LinkedIn first comment failedWB Node 7Check LinkedIn URN is set correctly; add comment manually if needed
LinkedIn token expiredWorkflow D sends email alert automaticallyOpen localhost:5678 → Credentials → LinkedIn Account → Reconnect
Twitter postingManual stepCopy tweet thread from Tweet Thread column in sheet → post on X
RSS feed emptyFetch RSS Feed nodeArticle must have published: true in frontmatter AND be deployed
Email not sendingEmail Review Request nodeRe-authorize Google Account credential
Brand guidelines not loadingNode 9 (Read Brand Guidelines)Check sheet has a Brand Guidelines tab with Key = linkedin row

n8n Useful Commands

bash
123456789101112
# 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 -d

Auto-start on Mac login: Docker Desktop → Settings → General → enable "Start Docker Desktop when you log in"

MH

Mohamed Hamed

20 years building production systems — the last several deep in AI integration, LLMs, and full-stack architecture. I write what I've actually built and broken. If this was useful, the next one goes to LinkedIn first.

Follow on LinkedIn →