{
  "name": "01 - Generate and Post to LinkedIn",
  "settings": {
    "executionOrder": "v1"
  },
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 9 * * *"
            }
          ]
        }
      },
      "id": "trigger-daily",
      "name": "Daily Trigger 9am",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        -220,
        0
      ],
      "notes": "Runs in the n8n instance timezone, not UTC. Set GENERIC_TIMEZONE in .env to your IANA zone (e.g. Asia/Kolkata, Europe/Zurich) - it defaults to America/New_York. No per-workflow timezone is set, so this one setting controls every schedule."
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "UPDATE topics\nSET last_used_at = now()\nWHERE id = (\n  SELECT id FROM topics\n  WHERE active = true\n  ORDER BY last_used_at NULLS FIRST, id\n  LIMIT 1\n  FOR UPDATE SKIP LOCKED\n)\nRETURNING id AS topic_id, topic, angle;",
        "options": {}
      },
      "id": "pick-topic",
      "name": "Pick Topic",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        0,
        0
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "notes": "Atomically claims the least-recently-used active topic and stamps last_used_at, so topics rotate evenly and two concurrent runs can never pick the same one. Returns zero rows if no topics are active - the workflow then stops here."
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.anthropic.com/v1/messages",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "anthropic-version",
              "value": "2023-06-01"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({\n  model: \"claude-sonnet-5\",\n  max_tokens: 600,\n  thinking: { type: \"disabled\" },\n  messages: [{\n    role: \"user\",\n    content: `Write a LinkedIn post for a business in this space. Topic: ${$json.topic}. Focus on: ${$json.angle}.\\n\\nRequirements:\\n- 100-150 words\\n- Friendly, helpful, non-salesy tone\\n- Start with a hook, not a greeting\\n- Include one practical, genuinely useful tip\\n- End with a soft call-to-action (e.g. inviting questions in comments)\\n- 3-5 relevant hashtags at the end\\n- At most 1-2 emojis\\n- Output ONLY the post text, nothing else`\n  }]\n}) }}",
        "options": {}
      },
      "id": "call-claude",
      "name": "Generate Post with Claude",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        220,
        0
      ],
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "notes": "Credential must be Header Auth with Name = x-api-key and Value = your sk-ant-... key.\n\nthinking is explicitly disabled: Claude Sonnet 5 thinks by default, and thinking tokens count against max_tokens, which would truncate the post."
    },
    {
      "parameters": {
        "jsCode": "// Validate Claude's response before anything gets published.\nconst res = $input.first().json;\n\nif (res.stop_reason === 'refusal') {\n  throw new Error('Claude declined to generate this post. Review the topic/angle wording in the topics table.');\n}\n\nif (res.stop_reason === 'max_tokens') {\n  throw new Error('Post was truncated (hit max_tokens). Raise max_tokens in the \"Generate Post with Claude\" node.');\n}\n\nconst textBlock = (res.content || []).find((b) => b.type === 'text');\n\nif (!textBlock || !textBlock.text || !textBlock.text.trim()) {\n  throw new Error('Claude returned no text content. Raw response: ' + JSON.stringify(res).slice(0, 500));\n}\n\nconst picked = $('Pick Topic').first().json;\n\nreturn [{\n  json: {\n    topic_id: picked.topic_id,\n    topic: picked.topic,\n    model: res.model,\n    postText: textBlock.text.trim()\n  }\n}];"
      },
      "id": "extract-text",
      "name": "Extract Post Text",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        0
      ],
      "notes": "Fails loudly on refusal, truncation, or an unexpected response shape - a half-finished post should never reach LinkedIn."
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.linkedin.com/rest/posts",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "LinkedIn-Version",
              "value": "202601"
            },
            {
              "name": "X-Restli-Protocol-Version",
              "value": "2.0.0"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({\n  author: $env.LINKEDIN_PERSON_URN,\n  commentary: $json.postText,\n  visibility: \"PUBLIC\",\n  distribution: {\n    feedDistribution: \"MAIN_FEED\",\n    targetEntities: [],\n    thirdPartyDistributionChannels: []\n  },\n  lifecycleState: \"PUBLISHED\",\n  isReshareDisabledByAuthor: false\n}) }}",
        "options": {
          "response": {
            "response": {
              "fullResponse": true
            }
          }
        }
      },
      "id": "post-linkedin",
      "name": "Publish to LinkedIn",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        660,
        0
      ],
      "onError": "continueErrorOutput",
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "notes": "Credential must be Header Auth with Name = Authorization and Value = Bearer YOUR_ACCESS_TOKEN.\n\nfullResponse is REQUIRED: LinkedIn returns the new post's URN in the x-restli-id response header, and without this the body-only response has no way to reach it. No URN means workflow 02 can never fetch engagement for this post.\n\nOn error, execution continues down the second output so the failure is still recorded."
    },
    {
      "parameters": {
        "operation": "insert",
        "schema": {
          "value": "public"
        },
        "table": {
          "value": "posts"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "topic_id": "={{ $('Extract Post Text').first().json.topic_id }}",
            "topic": "={{ $('Extract Post Text').first().json.topic }}",
            "post_text": "={{ $('Extract Post Text').first().json.postText }}",
            "model": "={{ $('Extract Post Text').first().json.model }}",
            "linkedin_urn": "={{ $json.headers['x-restli-id'] }}",
            "status": "posted"
          }
        },
        "options": {}
      },
      "id": "log-post",
      "name": "Log Published Post",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        900,
        -100
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "operation": "insert",
        "schema": {
          "value": "public"
        },
        "table": {
          "value": "posts"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "topic_id": "={{ $('Extract Post Text').first().json.topic_id }}",
            "topic": "={{ $('Extract Post Text').first().json.topic }}",
            "post_text": "={{ $('Extract Post Text').first().json.postText }}",
            "model": "={{ $('Extract Post Text').first().json.model }}",
            "status": "failed",
            "error": "={{ ($json.error && ($json.error.message || $json.error)) || 'LinkedIn publish failed' }}"
          }
        },
        "options": {}
      },
      "id": "log-failure",
      "name": "Log Failed Post",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        900,
        120
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "notes": "Keeps the generated text when LinkedIn rejects the request, so nothing is lost and the dashboard can show what failed."
    }
  ],
  "connections": {
    "Daily Trigger 9am": {
      "main": [
        [
          {
            "node": "Pick Topic",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick Topic": {
      "main": [
        [
          {
            "node": "Generate Post with Claude",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Generate Post with Claude": {
      "main": [
        [
          {
            "node": "Extract Post Text",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract Post Text": {
      "main": [
        [
          {
            "node": "Publish to LinkedIn",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Publish to LinkedIn": {
      "main": [
        [
          {
            "node": "Log Published Post",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Log Failed Post",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}