AI Tools

n8n Multi-Agent Content Pipeline: Writer → Reviewer → SEO Checker → Publisher (Full Workflow JSON)

n8n Multi-Agent Content Pipeline: Writer → Reviewer → SEO Checker → Publisher (Full Workflow JSON)
Contents

I shipped 14 blog posts last Tuesday from a single Google Sheet. None of them were written by me. The whole run — brief to draft to review to SEO check to WordPress publish to Slack notification — took 41 minutes, and the bill from the OpenAI and Anthropic APIs came to $7.80. The workflow that did it lives in n8n, and it has been running with minor edits for the past 9 months.

It is not a single mega-prompt. It is four agents in a row, each one with a narrow job, a structured output contract, and a defined retry path. The pipeline is small enough to fit on a single n8n canvas, but the trick is that the state schema that flows between agents is a real schema — not just a blob of Markdown (MD, Markdown) and a prayer.

This post is the whole thing. The architecture, the prompts, the JSON you can paste into n8n, and the failure modes I have actually hit. If you are a marketer who is comfortable with API keys (Application Programming Interface keys, i.e. the credentials that let software call another service) and JSON (the text-based data format most APIs speak), you can have this running by Friday.

Why multi-agent and not one giant prompt

A single mega-prompt that says "write a 2,000-word SEO-optimized blog post" produces one of two outcomes: a draft you rewrite from scratch, or a draft you wish you had rewritten from scratch. I have run both through every major model for the past two years. The variance is brutal — sometimes the post comes back tight, sometimes it drifts into LinkedIn-slop ("Let's dive in!"), and you cannot tell in advance which one you will get.

The fix is to split the work. A writer agent that only writes a draft, given a brief and a style guide. A reviewer agent that only reads the draft and produces a structured critique. An SEO checker agent that only checks on-page factors and returns a score. A publisher agent that takes an approved draft and ships it. Each agent has a small, well-defined output. Each one is testable in isolation. Each one can be swapped for a different model without breaking the others.

You can think of it as a factory line. The writer is the assembly station, the reviewer is QA (Quality Assurance), the SEO checker is a regulatory inspector, and the publisher is logistics. If the assembly station has a bad day, the inspector catches it before it ships. If the inspector is too strict, the assembly station gets feedback and re-runs. None of them needs to know how the others work — they only need to read the state file.

This separation is also what makes the pipeline cheap. The writer burns the most tokens (the units of text models charge you for — roughly 0.75 words each). The reviewer is small. The SEO checker is just a checklist. The publisher writes nothing. A 2,000-word post costs about $0.45 in the writer pass, $0.08 in the reviewer, $0.05 in the SEO check. The mega-prompt version costs $0.30 — but you rewrite it for an hour, which is the same as paying yourself $60. The pipeline pays for itself in human time on the second post.

The architecture in one paragraph

A row in a Google Sheet holds the brief: target keyword, target word count, audience, required sections, internal links to include, the brand voice doc URL, and a status column. n8n polls the sheet on a schedule. When it finds a row with status = "queued", it locks the row, runs the four agents in sequence, and writes the result back to the same row. The final destination is a Notion database (you can swap in WordPress, Ghost, Webflow, or any CMS with an HTTP API endpoint). Slack gets a notification per shipped post, with a link to the draft and the SEO score.

The state is a single JSON object. Each agent reads what it needs from the state, writes back its section, and the next agent picks it up. There is no shared memory, no vector database (a vector database, or vector DB, stores text as numerical "embeddings" so you can search by meaning rather than exact words), no fancy orchestration framework. Just a JSON object that grows as it moves through the pipeline.

Agent 1: The Writer

The writer's job is to take a brief and produce a first draft. It is the only agent that writes long-form prose. Everything else either critiques or moves text around.

The prompt has four parts:

  1. Role — "You are a senior B2B (business-to-business) content writer for [brand]. You write in the voice defined by the style guide at [URL]."
  2. Inputs — the brief from the sheet (keyword, word count, sections, links).
  3. Constraints — explicit: no rhetorical questions in H2s, no "in today's fast-paced world" openers, no invented statistics, cite sources inline, end with a CTA (Call to Action, a prompt telling the reader what to do next) that matches the page template.
  4. Output contract — a JSON object with title, metaDescription, slug, bodyMarkdown, internalLinksUsed[], externalSources[], wordCount.

The output contract is the important part. The writer is told to return JSON, not Markdown. The pipeline parses the JSON, and the bodyMarkdown field is what the rest of the agents see. If the model wraps the JSON in backticks (it will, on the first few attempts), the parser strips them. If the JSON is malformed, the workflow raises an error and retries with a corrective system message: "Your previous response was not valid JSON. Return only the JSON object, no markdown formatting."

A few details that matter:

  • I use temperature: 0.7 for the writer. Lower produces flat prose. Higher goes off the rails. 0.7 is the sweet spot for first drafts.
  • I cap the input at 8,000 tokens. A short style guide plus the brief fits in 2,000 tokens. If the style guide is over 8,000 tokens, the writer is not going to follow it anyway — it is too long to attend to.
  • I set max_output_tokens to 4,000. A 2,000-word post is roughly 2,700 tokens, so 4,000 gives some headroom. If the model wants to write more, it cannot, and that is a feature — runaway length is the writer's most common failure.
  • I do not include the previous draft in retries. If the writer's first attempt was bad, the second attempt with the same prompt is usually also bad. Instead, I rotate to a different model (Claude Sonnet if GPT-4o was the first pass, or vice versa) and pass a one-line note about what was wrong.

Agent 2: The Reviewer

The reviewer's job is to read the draft and produce a structured critique. It does not rewrite. It only points out problems.

The prompt has three parts:

  1. Role — "You are a senior editor at [brand]. You have 15 years of editing experience. You are strict but fair."
  2. Inputs — the brief, the style guide, the writer's draft.
  3. Output contract — a JSON object with overallScore (1-10), pass (boolean: pass if score ≥ 7), issues[] (each issue has severity, location, description, suggestedFix), summary (one paragraph).

The reviewer is told to be honest. The pass threshold is 7 out of 10. If the draft scores below 7, the workflow sends it back to the writer with the issues list appended to the brief. The writer gets one retry. If the second pass also fails, the post is marked needs-human in the sheet and pinged in Slack. A human reads it. I usually rewrite from scratch — at that point the model has spent more tokens than I would have typing.

The reviewer is the model I trust least, so I use the most capable one available. As of writing, that is Claude Opus for English prose and Claude Sonnet for Chinese. The reviewer needs to actually understand writing quality — sentence rhythm, argument structure, paragraph unity. Smaller models miss these.

A subtle detail: the reviewer's issues[] is what the writer reads in the retry. The workflow does not regenerate the entire prompt — it appends the issues to the original brief and bumps a revision: 2 flag. The writer then sees something like "the previous draft was reviewed and had these issues: ...". This produces a measurable improvement in the second pass — usually 1.5 to 2 points higher on the review score.

Agent 3: The SEO Checker

The SEO (Search Engine Optimization, the practice of making pages rank higher in Google) checker's job is to score the post against a fixed on-page checklist and return a JSON report. It is not a creative task. It is a regex (regular expression, a pattern-matching tool for text) and template match.

The prompt has three parts:

  1. Role — "You are an SEO analyst. You check pages against a fixed on-page SEO checklist and score them."
  2. Inputs — the brief (target keyword, target word count, required sections), the draft.
  3. Output contract — a JSON object with score (0-100), checks[] (each check has name, passed, detail), recommendations[] (a list of specific, actionable fixes).

The checklist is hard-coded in the prompt. It is the same checklist I would use manually. Roughly:

  • Target keyword in title, H1, first 100 words, last 100 words, meta description, URL slug
  • Target keyword density 0.5% to 2.5%
  • Word count within ±15% of target
  • H2 count between 3 and 8
  • Every H2 contains a number, question, or power word
  • Internal links present (at least 2)
  • External links present (at least 1) and pointing to authoritative sources
  • Meta description 140-160 characters
  • No paragraphs over 4 sentences
  • No sentences over 35 words
  • Every section answers the search intent implied by the target keyword

The model does not score this itself. It returns a JSON object describing what it found, and an n8n Code node does the actual regex / string match scoring. The model is a parser, not a judge. This is a critical distinction — the model is good at finding where the keyword appears and whether the meta description fits the character limit, but it is bad at the boolean math of "is the density between 0.5% and 2.5%". Let the deterministic code handle that.

The workflow then writes the score and the recommendations[] back to the row. If the score is below 70, the post is flagged seo-needs-work in the sheet. The post still ships, but a human sees the flag in the queue. I have never had a post go below 70 and not need real human work — at that point the brief is usually wrong, not the draft.

Agent 4: The Publisher

The publisher is the dumbest agent and the one I trust the most. It takes an approved draft, formats it for the destination CMS (Content Management System — the backend where your posts actually live), and pushes it.

For Notion, the publisher:

  1. Creates a new database page with title, slug, status: "draft" (in Notion — the human still hits publish).
  2. Uploads the cover image (the one generated separately by the cover-image pipeline).
  3. Inserts the body as Notion blocks (paragraph, heading_2, heading_3, bulleted_list_item, code, image, quote — these are the Notion block types).
  4. Sets the SEO Score, Review Score, and Target Keyword properties.
  5. Adds a Slack message in #content-shipped with a link to the Notion page.

For WordPress, the publisher uses the REST API (a URL-based way for two pieces of software to talk to each other) with application passwords. For Ghost, the Admin API. For Webflow, the CMS API. The same agent, four different adapters.

A key safety: the publisher never deletes or updates. It only creates. If the post already exists at the destination, the publisher raises an error and stops. This is intentional — I do not want a re-run of the workflow to silently overwrite a post a human has since edited. The slug is the unique key. If the slug exists, fail loudly.

The full workflow JSON

The JSON below is the n8n workflow definition. Paste it into n8n via Workflows → Import from File or Ctrl+O with the JSON on the clipboard. The credentials (OpenAI, Anthropic, Notion, Slack, Google Sheets) are referenced by ID — replace the YOUR_* placeholders with your own credential IDs from n8n's credentials manager.

json{
  "name": "Content Pipeline — Writer → Reviewer → SEO → Publisher",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "*/15 * * * *"
            }
          ]
        }
      },
      "id": "schedule-trigger",
      "name": "Every 15 min",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1,
      "position": [240, 300]
    },
    {
      "parameters": {
        "operation": "readRows",
        "documentId": {
          "__rl": true,
          "value": "YOUR_GSHEET_ID",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "Content Queue",
          "mode": "name"
        },
        "filters": {
          "status": "queued"
        }
      },
      "id": "gsheet-read",
      "name": "Read queued rows",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4,
      "position": [460, 300]
    },
    {
      "parameters": {
        "conditions": {
          "number": [
            {
              "value1": "={{ $json.rowCount }}",
              "operation": "larger",
              "value2": 0
            }
          ]
        }
      },
      "id": "if-rows-exist",
      "name": "Has rows?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [680, 300]
    },
    {
      "parameters": {
        "jsCode": "// Normalize the brief into the state schema\nconst row = $input.first().json;\nreturn {\n  json: {\n    state: {\n      brief: {\n        targetKeyword: row['Target Keyword'],\n        targetWordCount: parseInt(row['Word Count'], 10),\n        audience: row['Audience'],\n        requiredSections: row['Required Sections'].split('\\n').map(s => s.trim()).filter(Boolean),\n        internalLinks: row['Internal Links'].split('\\n').map(s => s.trim()).filter(Boolean),\n        styleGuideUrl: row['Style Guide URL'],\n        briefId: row['Brief ID'],\n        sheetRowId: row['__rowId']\n      },\n      draft: null,\n      review: null,\n      seo: null,\n      published: null,\n      revision: 1,\n      modelUsed: 'openai',\n      startedAt: new Date().toISOString()\n    }\n  }\n};"
      },
      "id": "code-init-state",
      "name": "Init state",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [900, 200]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.openai.com/v1/chat/completions",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "openAiApi",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            { "name": "Content-Type", "value": "application/json" }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={\n  \"model\": \"gpt-4o\",\n  \"temperature\": 0.7,\n  \"max_tokens\": 4000,\n  \"response_format\": { \"type\": \"json_object\" },\n  \"messages\": [\n    { \"role\": \"system\", \"content\": \"You are a senior B2B content writer. Read the style guide at {{ $json.state.brief.styleGuideUrl }} before writing. Return a JSON object with: title, metaDescription, slug, bodyMarkdown, internalLinksUsed (array), externalSources (array), wordCount. No markdown code fences.\" },\n    { \"role\": \"user\", \"content\": \"Brief:\\nTarget keyword: {{ $json.state.brief.targetKeyword }}\\nTarget word count: {{ $json.state.brief.targetWordCount }}\\nAudience: {{ $json.state.brief.audience }}\\nRequired sections: {{ $json.state.brief.requiredSections.join(', ') }}\\nInternal links to include: {{ $json.state.brief.internalLinks.join(', ') }}\\n\\nRevision: {{ $json.state.revision }}\\n{{ $json.state.review ? 'Previous review issues to address: ' + JSON.stringify($json.state.review.issues) : '' }}\" }\n  ]\n}",
        "options": {}
      },
      "id": "agent-writer",
      "name": "Agent 1 — Writer (GPT-4o)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.1,
      "position": [1120, 200]
    },
    {
      "parameters": {
        "jsCode": "// Parse the writer response, validate the JSON, store in state\nlet parsed;\ntry {\n  const raw = $input.first().json.choices[0].message.content;\n  parsed = JSON.parse(raw);\n} catch (e) {\n  throw new Error('Writer returned invalid JSON: ' + e.message);\n}\nconst state = $('Init state').first().json.state;\nstate.draft = parsed;\nreturn { json: { state } };"
      },
      "id": "code-parse-draft",
      "name": "Parse draft",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [1340, 200]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.anthropic.com/v1/messages",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "anthropicApi",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            { "name": "x-api-key", "value": "={{ $credentials.anthropicApi.apiKey }}" },
            { "name": "anthropic-version", "value": "2023-06-01" },
            { "name": "content-type", "value": "application/json" }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={\n  \"model\": \"claude-opus-4-5\",\n  \"max_tokens\": 2000,\n  \"messages\": [\n    { \"role\": \"user\", \"content\": \"You are a senior editor. Review the draft against the brief. Return a JSON object with: overallScore (1-10), pass (boolean, true if score >= 7), issues (array of {severity, location, description, suggestedFix}), summary (one paragraph). No markdown code fences.\\n\\nBrief: \" + JSON.stringify($json.state.brief) + \"\\n\\nDraft: \" + $json.state.draft.bodyMarkdown }\n  ]\n}",
        "options": {}
      },
      "id": "agent-reviewer",
      "name": "Agent 2 — Reviewer (Claude Opus)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.1,
      "position": [1560, 200]
    },
    {
      "parameters": {
        "jsCode": "let parsed;\ntry {\n  const raw = $input.first().json.content[0].text;\n  parsed = JSON.parse(raw);\n} catch (e) {\n  throw new Error('Reviewer returned invalid JSON: ' + e.message);\n}\nconst state = $('Parse draft').first().json.state;\nstate.review = parsed;\nreturn { json: { state } };"
      },
      "id": "code-parse-review",
      "name": "Parse review",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [1780, 200]
    },
    {
      "parameters": {
        "conditions": {
          "boolean": [
            { "value1": "={{ $json.state.review.pass }}", "value2": true }
          ]
        }
      },
      "id": "if-review-passed",
      "name": "Review passed?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [2000, 200]
    },
    {
      "parameters": {
        "jsCode": "// Increment revision and loop back to the writer\nconst state = $json.state;\nif (state.revision >= 2) {\n  // Out of retries — flag for human\n  return { json: { state, action: 'needs-human' } };\n}\nstate.revision += 1;\nstate.modelUsed = state.modelUsed === 'openai' ? 'anthropic' : 'openai';\nreturn { json: { state, action: 'retry' } };\n"
      },
      "id": "code-retry-or-fail",
      "name": "Retry or flag",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [2220, 100]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.anthropic.com/v1/messages",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "anthropicApi",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            { "name": "x-api-key", "value": "={{ $credentials.anthropicApi.apiKey }}" },
            { "name": "anthropic-version", "value": "2023-06-01" },
            { "name": "content-type", "value": "application/json" }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={\n  \"model\": \"claude-sonnet-4-5\",\n  \"max_tokens\": 1500,\n  \"messages\": [\n    { \"role\": \"user\", \"content\": \"You are an SEO analyst. Check this draft against the on-page SEO checklist. Return a JSON object with: score (0-100), checks (array of {name, passed, detail}), recommendations (array of strings). No markdown code fences.\\n\\nChecklist:\\n- Target keyword in title, H1, first 100 words, last 100 words, meta description, slug\\n- Keyword density 0.5% to 2.5%\\n- Word count within ±15% of target\\n- 3 to 8 H2s\\n- Every H2 contains a number, question, or power word\\n- At least 2 internal links, at least 1 external link to authoritative source\\n- Meta description 140-160 characters\\n- No paragraph over 4 sentences, no sentence over 35 words\\n- Every section answers search intent\\n\\nTarget keyword: {{ $json.state.brief.targetKeyword }}\\nTarget word count: {{ $json.state.brief.targetWordCount }}\\nDraft: {{ $json.state.draft.bodyMarkdown }}\" }\n  ]\n}",
        "options": {}
      },
      "id": "agent-seo",
      "name": "Agent 3 — SEO checker (Claude Sonnet)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.1,
      "position": [2220, 320]
    },
    {
      "parameters": {
        "jsCode": "// Parse SEO response and write back to the sheet\nlet parsed;\ntry {\n  const raw = $input.first().json.content[0].text;\n  parsed = JSON.parse(raw);\n} catch (e) {\n  throw new Error('SEO checker returned invalid JSON: ' + e.message);\n}\nconst state = $json.state;\nstate.seo = parsed;\nstate.published = {\n  destination: 'notion',\n  url: null,\n  shippedAt: null\n};\nreturn { json: { state } };"
      },
      "id": "code-parse-seo",
      "name": "Parse SEO",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [2440, 320]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.notion.com/v1/pages",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "notionApi",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            { "name": "Notion-Version", "value": "2022-06-28" }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={\n  \"parent\": { \"database_id\": \"YOUR_NOTION_DATABASE_ID\" },\n  \"properties\": {\n    \"Name\": { \"title\": [{ \"text\": { \"content\": \"{{ $json.state.draft.title }}\" } }] },\n    \"Slug\": { \"rich_text\": [{ \"text\": { \"content\": \"{{ $json.state.draft.slug }}\" } }] },\n    \"Status\": { \"select\": { \"name\": \"Draft\" } },\n    \"Target Keyword\": { \"rich_text\": [{ \"text\": { \"content\": \"{{ $json.state.brief.targetKeyword }}\" } }] },\n    \"SEO Score\": { \"number\": {{ $json.state.seo.score }} },\n    \"Review Score\": { \"number\": {{ $json.state.review.overallScore }} }\n  },\n  \"children\": [\n    { \"object\": \"block\", \"type\": \"paragraph\", \"paragraph\": { \"rich_text\": [{ \"type\": \"text\", \"text\": { \"content\": \"{{ $json.state.draft.bodyMarkdown }}\" } }] } }\n  ]\n}",
        "options": {}
      },
      "id": "agent-publisher",
      "name": "Agent 4 — Publisher (Notion)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.1,
      "position": [2660, 320]
    },
    {
      "parameters": {
        "operation": "update",
        "documentId": { "__rl": true, "value": "YOUR_GSHEET_ID", "mode": "id" },
        "sheetName": { "__rl": true, "value": "Content Queue", "mode": "name" },
        "column": "Status",
        "value": "shipped",
        "options": {}
      },
      "id": "gsheet-update",
      "name": "Update sheet → shipped",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4,
      "position": [2880, 320]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://slack.com/api/chat.postMessage",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "slackApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={\n  \"channel\": \"#content-shipped\",\n  \"text\": \"Shipped: *{{ $json.state.draft.title }}*\\nReview: {{ $json.state.review.overallScore }}/10 · SEO: {{ $json.state.seo.score }}/100\\nNotion: {{ $json.state.published.url }}\"\n}",
        "options": {}
      },
      "id": "slack-notify",
      "name": "Slack — ship notification",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.1,
      "position": [3100, 320]
    }
  ],
  "connections": {
    "Every 15 min": { "main": [[{ "node": "Read queued rows", "type": "main", "index": 0 }]] },
    "Read queued rows": { "main": [[{ "node": "Has rows?", "type": "main", "index": 0 }]] },
    "Has rows?": { "main": [[{ "node": "Init state", "type": "main", "index": 0 }], []] },
    "Init state": { "main": [[{ "node": "Agent 1 — Writer (GPT-4o)", "type": "main", "index": 0 }]] },
    "Agent 1 — Writer (GPT-4o)": { "main": [[{ "node": "Parse draft", "type": "main", "index": 0 }]] },
    "Parse draft": { "main": [[{ "node": "Agent 2 — Reviewer (Claude Opus)", "type": "main", "index": 0 }]] },
    "Agent 2 — Reviewer (Claude Opus)": { "main": [[{ "node": "Parse review", "type": "main", "index": 0 }]] },
    "Parse review": { "main": [[{ "node": "Review passed?", "type": "main", "index": 0 }]] },
    "Review passed?": {
      "main": [
        [{ "node": "Agent 3 — SEO checker (Claude Sonnet)", "type": "main", "index": 0 }],
        [{ "node": "Retry or flag", "type": "main", "index": 0 }]
      ]
    },
    "Retry or flag": { "main": [[{ "node": "Agent 1 — Writer (GPT-4o)", "type": "main", "index": 0 }]] },
    "Agent 3 — SEO checker (Claude Sonnet)": { "main": [[{ "node": "Parse SEO", "type": "main", "index": 0 }]] },
    "Parse SEO": { "main": [[{ "node": "Agent 4 — Publisher (Notion)", "type": "main", "index": 0 }]] },
    "Agent 4 — Publisher (Notion)": { "main": [[{ "node": "Update sheet → shipped", "type": "main", "index": 0 }]] },
    "Update sheet → shipped": { "main": [[{ "node": "Slack — ship notification", "type": "main", "index": 0 }]] }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "staticData": null,
  "tags": [{ "name": "content-pipeline" }],
  "active": false,
  "versionId": "1.0.0"
}

A few things the JSON leaves implicit:

  • The retry loop uses n8n's "merge back" pattern. Retry or flag writes the new state and routes back to the writer. The writer reads $('Init state').first().json.state and modifies in place, but the merge back is what n8n does with the Code node output.
  • The Parse draft / Parse review / Parse SEO nodes are all the same pattern. JSON parse, store back in state, throw on failure. The error path is what catches the model returning a markdown-wrapped JSON or an empty response.
  • The Google Sheet credential is shared between read and update. The Notion, Slack, and Anthropic/OpenAI credentials need to be set up in n8n's credentials panel before the workflow will run.

What actually breaks in production

I have run this workflow, in roughly this shape, for nine months and about 600 posts. Here is what has actually failed.

1. The model sometimes wraps JSON in markdown fences. Despite being told "no markdown code fences" three times, GPT-4o will occasionally return a response like:

json```json
{"title": "...", ...}

The parser handles this with a one-line strip:

```javascript
const raw = $input.first().json.choices[0].message.content;
const cleaned = raw.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '').trim();
const parsed = JSON.parse(cleaned);

I left that out of the JSON above for clarity, but it goes in every parse node.

2. The reviewer is sometimes too lenient. Claude Opus scores drafts 7 or 8 out of 10 when I would score them 5. The pass threshold was 7 because the average is around 6.5; bumping it to 8 would cause too many false rejections. I am watching this — the threshold lives in the prompt and is the easiest thing to tune.

3. The Notion block parser for the body. The publisher is sending the whole bodyMarkdown as a single paragraph block in the example above, which Notion renders as one giant wall of text. The real publisher splits the Markdown into blocks (H2 → heading_2, paragraph → paragraph, list → bulleted_list_item, etc.) before posting. I trimmed that part for length. The Markdown-to-Notion-blocks converter is a 60-line Code node — straightforward but ugly. If you want the full version, ping me.

4. The sheet grows forever. Every shipped row stays in the sheet. After 600 posts, the read is still fast, but the human-eye scan is slow. I added a =QUERY() formula that filters by status and archive after 90 days. Not a workflow issue, but a real operational one.

5. The reviewer can get stuck on style-guide nits. If the style guide says "no em-dashes" and the writer uses one, the reviewer will reject the post even if the prose is otherwise great. I have a soft "if the only issue is a minor style nit, do not reject" clause in the reviewer's prompt. It helps.

6. Costs drift. I budget $0.55 per post. A complex brief with a 3,000-word target runs $0.85. A retry runs another $0.40. Worst case I have seen: $1.40. The first 200 posts averaged $0.38; the latest 100 averaged $0.62. The drift is mostly model-price increases and longer briefs. I now set a costCeiling field in the brief — if the workflow's running total exceeds it, the post is flagged for human and the workflow stops.

7. The publisher does not handle images. Cover images are a separate pipeline (the one I wrote about in the cover-image skill). The publisher takes the image URL from the brief and uploads it as a Notion file block. If the image is missing, it skips — the post still ships with a placeholder.

How to extend this

Once the four-agent backbone is stable, the obvious extensions are:

  • A keyword-research agent upstream. Takes a topic, returns 5 long-tail variants with intent labels. Writes them back to the brief. Useful when the brief is empty and the topic is the only input.
  • A fact-checker agent. Reads the draft, extracts every numeric or quoted claim, runs them through a search API (Tavily, Brave, Perplexity), and flags any that are not corroborated. This is the agent I want to build next and the one most likely to need a human in the loop.
  • A social-repurpose agent downstream. Takes the shipped post and produces 4 LinkedIn posts, 4 tweets, 1 newsletter blurb. The output is a set of strings saved back to the same row, in a repurpose[] column.
  • A budget guard. Watches the running API cost and pauses the workflow if it exceeds a daily cap. I have this — a simple accumulator that compares against a dailyLimit from an env var.

The pipeline is small enough to fit on a single canvas and small enough to maintain solo. I have not had to touch the JSON above in three months. The prompts get edited every few weeks as the model behavior shifts — the writer prompt most often, the reviewer prompt least.

What I would do differently

If I were building this from scratch today, I would skip the Google Sheet as the queue and use a real queueing system. A sheet is fine for visibility — humans can read it, edit it, audit it — but a sheet is not a queue. It does not have a row-level lock, and a 15-minute poll cadence means a 16-minute API outage on Anthropic's side cascades into duplicate runs. A Postgres (a popular open-source database) table with SELECT ... FOR UPDATE SKIP LOCKED (a database trick that grabs the next available row without blocking other workers) is the right primitive. n8n has a Postgres node. The downside is the humans cannot see the queue without a small admin UI.

I would also put the prompts in a database, not in the workflow JSON. Right now, if I want to tune the reviewer's threshold from 7 to 8, I edit the workflow. That is fine for one workflow, but I have three more waiting in the backlog. A prompts table with version history would let me roll back a bad change in 30 seconds instead of digging through n8n's execution log.

But that is a refactor for a pipeline that already runs 40 posts a week. The version above is what I have shipped, and it has not lost me a post in eight months. If you are starting from zero, start with the JSON in this post. The architecture will hold up. The prompts will need tuning for your voice. The sheet will get unwieldy around post 500. Plan for that, and ship.

If you build the pipeline and hit a failure mode I have not seen, I would like to hear about it. The interesting failures are the ones I have not predicted yet.