AutomationFlowsData & Sheets › Schedule Bluesky Posts and Threads Using Google Sheets as Content Calendar

Schedule Bluesky Posts and Threads Using Google Sheets as Content Calendar

BySoumya Sahu @sahu on n8n.io

This workflow turns a Google Sheet into a fully automated content calendar for BlueSky. It handles single posts, multi-post threads, and image attachments, allowing you to manage your entire social presence from a simple spreadsheet.

Cron / scheduled trigger★★★★★ complexity35 nodesGoogle SheetsHTTP Request
Data & Sheets Trigger: Cron / scheduled Nodes: 35 Complexity: ★★★★★ Added:

This workflow corresponds to n8n.io template #12153 — we link there as the canonical source.

This workflow follows the Google Sheets → HTTP Request recipe pattern — see all workflows that pair these two integrations.

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "id": "1tR0c9AtnzjrWq5w",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "BlueSky Suite: Schedule BlueSky posts and threads from Google Sheets",
  "tags": [],
  "nodes": [
    {
      "id": "247a7105-ed40-4051-bf26-b65d1c23a6ca",
      "name": "Schedule Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        -576,
        0
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "minutes",
              "minutesInterval": 15
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "ce6a92cb-b874-4589-8955-3d3e93be959b",
      "name": "Get row(s) in sheet",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        272,
        0
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "",
          "cachedResultUrl": "",
          "cachedResultName": ""
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "",
          "cachedResultUrl": "",
          "cachedResultName": ""
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "930d47a1-75a7-4b19-babc-3af2f571f437",
      "name": "Sort",
      "type": "n8n-nodes-base.sort",
      "position": [
        880,
        0
      ],
      "parameters": {
        "options": {},
        "sortFieldsUi": {
          "sortField": [
            {
              "fieldName": "Thread ID"
            },
            {
              "fieldName": "Sequence"
            }
          ]
        }
      },
      "typeVersion": 1
    },
    {
      "id": "16b10c78-8e3f-4764-abbd-5a76ad1d930c",
      "name": "Loop Over Items",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        1168,
        0
      ],
      "parameters": {
        "options": {}
      },
      "typeVersion": 3
    },
    {
      "id": "f049c216-68dd-45a8-99c6-cd507affee50",
      "name": "If",
      "type": "n8n-nodes-base.if",
      "position": [
        1472,
        16
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "d8125374-12b8-49cc-b1fe-4e6229186f82",
              "operator": {
                "type": "string",
                "operation": "notEmpty",
                "singleValue": true
              },
              "leftValue": "={{ $json[\"Image URL\"] }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "cd9bb8fd-18f0-4d26-b3a5-80552c3eeb27",
      "name": "HTTP Download Image",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueRegularOutput",
      "position": [
        1648,
        -128
      ],
      "parameters": {
        "url": "={{ $json[\"Image URL\"] }}",
        "options": {}
      },
      "typeVersion": 4.3
    },
    {
      "id": "d1bcfb1c-3d1c-4f75-8012-ea7dfea41639",
      "name": "Update row in sheet",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        3440,
        16
      ],
      "parameters": {
        "columns": {
          "value": {
            "Status": "Posted",
            "Post Link": "={{ $json.postLink }}",
            "Posted At": "={{ $now.setZone($('Configuration').first().json.timezone || 'UTC').toFormat('yyyy-MM-dd HH:mm') }}",
            "row_number": "={{ $('Loop Over Items').item.json.row_number }}"
          },
          "schema": [
            {
              "id": "Content",
              "type": "string",
              "display": true,
              "removed": true,
              "required": false,
              "displayName": "Content",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Thread ID",
              "type": "string",
              "display": true,
              "removed": true,
              "required": false,
              "displayName": "Thread ID",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Sequence",
              "type": "string",
              "display": true,
              "removed": true,
              "required": false,
              "displayName": "Sequence",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Image URL",
              "type": "string",
              "display": true,
              "removed": true,
              "required": false,
              "displayName": "Image URL",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Scheduled Time",
              "type": "string",
              "display": true,
              "removed": true,
              "required": false,
              "displayName": "Scheduled Time",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Status",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Status",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Posted At",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Posted At",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Post Link",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Post Link",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Post URI",
              "type": "string",
              "display": true,
              "removed": true,
              "required": false,
              "displayName": "Post URI",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Like Count",
              "type": "string",
              "display": true,
              "removed": true,
              "required": false,
              "displayName": "Like Count",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Repost Count",
              "type": "string",
              "display": true,
              "removed": true,
              "required": false,
              "displayName": "Repost Count",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Reply Count",
              "type": "string",
              "display": true,
              "removed": true,
              "required": false,
              "displayName": "Reply Count",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "row_number",
              "type": "number",
              "display": true,
              "removed": false,
              "readOnly": true,
              "required": false,
              "displayName": "row_number",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [
            "row_number"
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "update",
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "",
          "cachedResultUrl": "",
          "cachedResultName": ""
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "",
          "cachedResultUrl": "",
          "cachedResultName": ""
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "abe0d905-1908-46b6-915b-c6685a817b3b",
      "name": "Construct Payload",
      "type": "n8n-nodes-base.code",
      "position": [
        2576,
        16
      ],
      "parameters": {
        "jsCode": "const staticData = $getWorkflowStaticData('global');\nconst items = $input.all();\nconst results = [];\n\nfor (const item of items) {\n  const json = item.json;\n  \n  // --- 1. SETUP VARIABLES ---\n  // Now safe to access because we fixed the data flow!\n  const threadId = json[\"Thread ID\"]; \n  const sequence = parseInt(json[\"Sequence\"] || 1);\n  const did = $('BlueSky Auth').first().json.did;\n  const postBody = json[\"Content\"]; // Using the correct column name from your sheet\n\n  // --- 2. CONSTRUCT BASIC RECORD ---\n  let record = {\n    text: postBody,\n    createdAt: new Date().toISOString(),\n    $type: \"app.bsky.feed.post\"\n  };\n\n  // --- 3. HANDLE IMAGES (THE FIX) ---\n  // We now check if the CURRENT item's JSON has the 'blob' data.\n  // This data exists only if it came through the \"Combine Data\" merge node.\n  if (json.blob) {\n    record.embed = {\n      $type: \"app.bsky.embed.images\",\n      images: [{\n        alt: json[\"Alt Text\"] || \"\", // Default to empty string if missing\n        image: json.blob // Use the blob directly from the item's data\n      }]\n    };\n  }\n\n  // --- 4. HANDLE THREADING (THE FIX) ---\n  // This logic now works because 'sequence' and 'threadId' are correctly retrieved.\n  if (sequence === 1) {\n    // New thread: reset global pointers\n    staticData.currentRoot = null;\n    staticData.currentParent = null;\n    staticData.currentThreadId = threadId;\n  } \n  else if (sequence > 1 && staticData.currentThreadId == threadId) {\n    // Reply: check if we have parent/root info from previous loop iteration\n    if (staticData.currentParent && staticData.currentRoot) {\n      record.reply = {\n        root: staticData.currentRoot,\n        parent: staticData.currentParent\n      };\n    }\n  }\n\n  // --- 5. FINALIZE ---\n  results.push({\n    json: {\n      repo: did,\n      collection: \"app.bsky.feed.post\",\n      record: record\n    }\n  });\n}\n\nreturn results;"
      },
      "typeVersion": 2
    },
    {
      "id": "aba87f26-ec9d-4c60-9c58-cfc2c3d85571",
      "name": "BlueSky Auth",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -96,
        0
      ],
      "parameters": {
        "url": "https://bsky.social/xrpc/com.atproto.server.createSession",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"identifier\":\"{{$('Configuration').first().json.bluesky_handle}}\",\n  \"password\": \"{{ $('Configuration').first().json.app_password }}\"\n}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.3
    },
    {
      "id": "35cf5776-c5e9-4b28-a63f-6902dec747c8",
      "name": "Create Post",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        2896,
        16
      ],
      "parameters": {
        "url": "https://bsky.social/xrpc/com.atproto.repo.createRecord",
        "method": "POST",
        "options": {},
        "jsonBody": "={{ $node[\"Construct Payload\"].data }}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "=Bearer {{ $('BlueSky Auth').first().json.accessJwt }}"
            }
          ]
        }
      },
      "typeVersion": 4.3
    },
    {
      "id": "57359aaa-c587-41bd-bb87-d621bcaa3ac8",
      "name": "Update Thread State",
      "type": "n8n-nodes-base.code",
      "position": [
        3184,
        16
      ],
      "parameters": {
        "jsCode": "const staticData = $getWorkflowStaticData('global');\n// Get the response from the Create Post node\nconst lastPost = $('Create Post').first().json; \nconst handle = $('BlueSky Auth').first().json.handle || $('BlueSky Auth').first().json.identifier; // Get username\n\n// --- 1. EXTRACT ID ---\n// The URI looks like: at://did:plc:1234/app.bsky.feed.post/3lb234...\n// We need the last part (\"3lb234...\") to build the web link\nconst uriParts = lastPost.uri.split('/');\nconst rkey = uriParts[uriParts.length - 1];\n\n// --- 2. BUILD WEB URL ---\n// Format: https://bsky.app/profile/{handle}/post/{id}\nconst webUrl = `https://bsky.app/profile/${handle}/post/${rkey}`;\n\n// --- 3. SAVE THREAD STATE (Existing Logic) ---\nconst newRef = {\n  uri: lastPost.uri,\n  cid: lastPost.cid\n};\n\nif (!staticData.currentRoot) {\n  staticData.currentRoot = newRef;\n}\nstaticData.currentParent = newRef;\n\n// --- 4. OUTPUT DATA FOR SHEET ---\n// We pass the original sheet data + our new links\nreturn [{\n  json: {\n    ...$input.first().json, // Keep original sheet data\n    postUri: lastPost.uri,  // The technical ID\n    postLink: webUrl        // The clickable link\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "0b3ab7f8-88ce-4b85-9f5b-1dbab47af201",
      "name": "Configuration",
      "type": "n8n-nodes-base.set",
      "position": [
        -336,
        0
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "4e2b2be1-d052-4dc8-96e8-60054b1cba1d",
              "name": "bluesky_handle",
              "type": "string",
              "value": ""
            },
            {
              "id": "b336da17-f730-49e6-85c9-4f8e9b3f8522",
              "name": "app_password",
              "type": "string",
              "value": ""
            },
            {
              "id": "8054c068-b9a2-4c82-975e-23499fbf0465",
              "name": "timezone",
              "type": "string",
              "value": "Asia/Kolkata"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "9a06d0c9-6f8a-4e2f-a304-606aa0f4b13a",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1520,
        -288
      ],
      "parameters": {
        "width": 832,
        "height": 624,
        "content": "# \ud83d\udcd8 Post Scheduler - How To Use\n**Goal:** Auto-post threads and images from Google Sheets to BlueSky.\n\n**Step 1: Setup Credentials**\nOpen the \"Configuration\" node (first green node) and enter your BlueSky Handle and App Password.\n\n**Step 2: Prepare Your Google Sheet** - Here is a [**Sample Google Sheet**](https://docs.google.com/spreadsheets/d/1Mg04gK1K5DBtJHrWw3ePRFc_JjkxwAp0deGjapVl2q0/edit?usp=sharing) \n\nEnsure your sheet has these columns:\n1. **Content:** The text of your post.\n2. **Thread ID:** Group posts together using the same ID (e.g., \"1002 or Thread-A\"). **>> Imp - Even if its a single post (not a thread), still you must add a unique thread id <<**\n3. **Sequence:** Order of the thread (1, 2, 3...). **>> Imp: Enter 1 even if it is a single post <<**\n4. **Image URL:** (Optional) Direct link to an image. **Must be valid image ending with png or jpg etc**\n5. **Status:** Mark rows as \"Ready\" to post them.\n6. **Scheduled Time (Column type - Plain Text):** Workflow checks if this time has passed, then creates a post. **Format YYYY-MM-DD HH:mm**\n\n\n**Step 3: Connect your google account**\nIf you already have google account connected already, use that or connect new account in the \"Get row(s) insheet\" Google sheet node. [More details about Google Sheets node here](https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.googlesheets/)\n\n**Step 4: Activate**\nTurn the workflow to \"Active\". It will run every hour (or your set interval), pick up \"Ready\" rows, post them, and update the status to \"Posted\"."
      },
      "typeVersion": 1
    },
    {
      "id": "a233dfba-973d-49e5-8662-e05e3f8e1de4",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -432,
        -208
      ],
      "parameters": {
        "width": 288,
        "height": 192,
        "content": "### 1- START HERE \nEnter your BlueSky Handle (e.g., steve.bsky.social), App Password and timezone (eg: America/Los_Angeles or Europe/Berlin etc) here.\n\n[Find your timezone name here](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)"
      },
      "typeVersion": 1
    },
    {
      "id": "aac4a7c0-1898-4c47-9b2f-bf93cd97c866",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        176,
        160
      ],
      "parameters": {
        "width": 304,
        "height": 272,
        "content": "### 3- Google sheets rows \nFetcher Grabs all rows where Status column is \"Ready\". If you want to test, manually add a row with \"Ready\" in your sheet first.\n**Must have columns -**\nContent | Thread ID | Sequence | Image URL | Scheduled Time | Status | Posted At | Post Link\n\n[**Sample Google Sheet**](https://docs.google.com/spreadsheets/d/1Mg04gK1K5DBtJHrWw3ePRFc_JjkxwAp0deGjapVl2q0/edit?usp=sharing)\nScheduled Time - Use column type as \"Plain Text\" to avoid errors"
      },
      "typeVersion": 1
    },
    {
      "id": "28dba190-c8ee-4c2b-a9b7-74cf4bc4545d",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -160,
        160
      ],
      "parameters": {
        "height": 176,
        "content": "### 2- Get access token\nLogin to BlueSky using handle and app password to get the access token. This will be used in all future BlueSky api calls."
      },
      "typeVersion": 1
    },
    {
      "id": "f3297a25-02b8-497a-a9cb-7d701db9747c",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        800,
        -192
      ],
      "parameters": {
        "height": 176,
        "content": "### 4- The Thread Organizer\n**Critically important!** This ensures \"Post 1\" runs before \"Post 2\" so threads are built in the correct order."
      },
      "typeVersion": 1
    },
    {
      "id": "bd26db50-83ca-49f9-93f2-26b581d845dd",
      "name": "Sticky Note5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1104,
        -176
      ],
      "parameters": {
        "content": "### 5- The Batch Processor\nProcesses posts one by one to ensure we handle image uploads and thread linking correctly."
      },
      "typeVersion": 1
    },
    {
      "id": "8819caed-b005-4cbc-9196-42d618ee0077",
      "name": "Sticky Note6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1520,
        -320
      ],
      "parameters": {
        "content": "### 6i- Download Image \nIf an image URL is found, we download it to re-upload it to BlueSky."
      },
      "typeVersion": 1
    },
    {
      "id": "b010ea61-1685-4b54-bfd6-b544f9461204",
      "name": "Sticky Note7",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1792,
        -320
      ],
      "parameters": {
        "content": "### 6ii- Upload Image \nUpload it to BlueSky's servers to get a \"Blob Link\" before posting."
      },
      "typeVersion": 1
    },
    {
      "id": "633901dd-5069-4683-90b4-0b990748f569",
      "name": "Upload Blob",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueRegularOutput",
      "position": [
        1856,
        -128
      ],
      "parameters": {
        "url": "https://bsky.social/xrpc/com.atproto.repo.uploadBlob",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "contentType": "binaryData",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "=Bearer {{ $('BlueSky Auth').first().json.accessJwt }}"
            },
            {
              "name": "Content-Type",
              "value": "={{ $binary.data.mimeType }}"
            }
          ]
        },
        "inputDataFieldName": "data"
      },
      "typeVersion": 4.3
    },
    {
      "id": "182c0acf-5f32-4870-924d-b7b29d6645ec",
      "name": "Sticky Note8",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2448,
        -240
      ],
      "parameters": {
        "width": 288,
        "height": 208,
        "content": "### 8- Construct Payload\n**The Logic Brain** This script builds the final data packet. It handles:\n\n- Attaching images (if any).\n- Linking replies (if Sequence > 1, it finds the parent ID).\n- Resetting memory for new threads."
      },
      "typeVersion": 1
    },
    {
      "id": "2a721a10-a65d-44ee-b9a0-7d9610fa8b92",
      "name": "Sticky Note9",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2816,
        -160
      ],
      "parameters": {
        "content": "### 9- Create Post\nCall BlueSky api to create a new post or reply to a post (if its a sequence in a thread)"
      },
      "typeVersion": 1
    },
    {
      "id": "34a9917c-7dd3-40ea-b977-bfe280a70293",
      "name": "Sticky Note10",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3072,
        -160
      ],
      "parameters": {
        "content": "### 10- Update Thread State\n**Memory Keeper** After a successful post, this saves the Post ID (uri and cid) into memory. The next item in the loop will look here to know which post to reply to."
      },
      "typeVersion": 1
    },
    {
      "id": "358c9ff0-8ecd-423e-95ed-5e9586b20a07",
      "name": "Sticky Note11",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3344,
        -160
      ],
      "parameters": {
        "content": "### 11- Update Google Sheets \n**The Closer** Writes \"Posted\" back to your sheet and saves the direct link to the live post so you have a record."
      },
      "typeVersion": 1
    },
    {
      "id": "52b4b478-cb17-421d-a9d0-54ac5ce925cd",
      "name": "Sticky Note12",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2784,
        -352
      ],
      "parameters": {
        "color": 7,
        "width": 816,
        "height": 816,
        "content": "# Create post and update Google sheet"
      },
      "typeVersion": 1
    },
    {
      "id": "8cb827c7-a323-4bf4-8e14-7eefc48718ec",
      "name": "Sticky Note13",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        112,
        -352
      ],
      "parameters": {
        "color": 7,
        "width": 592,
        "height": 816,
        "content": "# Inputs"
      },
      "typeVersion": 1
    },
    {
      "id": "5540c559-3aea-41d6-a799-f44a652da048",
      "name": "Sticky Note14",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        736,
        -352
      ],
      "parameters": {
        "color": 7,
        "width": 2016,
        "height": 816,
        "content": "# Posting Logic"
      },
      "typeVersion": 1
    },
    {
      "id": "73100de1-eeb8-48e0-a353-3d9c7b63e725",
      "name": "Attach Image Blob",
      "type": "n8n-nodes-base.merge",
      "position": [
        2160,
        -112
      ],
      "parameters": {
        "mode": "combine",
        "options": {},
        "combineBy": "combineByPosition"
      },
      "typeVersion": 3.2
    },
    {
      "id": "ed94e650-f36e-4f24-8aea-2b42931a572c",
      "name": "Consolidate Streams",
      "type": "n8n-nodes-base.merge",
      "position": [
        2352,
        16
      ],
      "parameters": {},
      "typeVersion": 3.2
    },
    {
      "id": "8f1c1597-e6b3-4a97-90df-2a61580fe22f",
      "name": "Sticky Note15",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2064,
        -336
      ],
      "parameters": {
        "width": 272,
        "height": 208,
        "content": "### 6iii- Re-assembling the Data \nThis node merges the \"Blob\" (the technical image file from BlueSky) back with your original Google Sheet data (Text, Thread ID, etc.).\n\nWithout this, the next step would have the image but forget what text goes with it!"
      },
      "typeVersion": 1
    },
    {
      "id": "f6a33102-6593-4d0e-b6e3-49aa645b1b9f",
      "name": "Sticky Note16",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2272,
        160
      ],
      "parameters": {
        "width": 288,
        "height": 208,
        "content": "### 7- The Reunion\nThis brings your \"Image Posts\" (top path) and \"Text-Only Posts\" (bottom path) back together into a single list.\n\nThis allows the **\"Construct Payload\"** node to handle all posts in one single script, keeping your workflow clean and DRY (Don't Repeat Yourself)."
      },
      "typeVersion": 1
    },
    {
      "id": "7b50984a-0e71-4f4c-bbc5-28f31bdeaa00",
      "name": "Sticky Note17",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1408,
        144
      ],
      "parameters": {
        "content": "### 6- If post contains image\n\nIf we got image url, then download it, upload to BlueSky and then return the combined data.\nElse continue."
      },
      "typeVersion": 1
    },
    {
      "id": "82b6857a-3698-4477-97ea-cd604f25a5ab",
      "name": "Filter",
      "type": "n8n-nodes-base.filter",
      "position": [
        496,
        0
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "1b57a1d9-892b-47a1-a4ae-313dfd71a9ae",
              "operator": {
                "name": "filter.operator.equals",
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{ $json.Status }}",
              "rightValue": "Ready"
            },
            {
              "id": "078a3331-6e90-4414-98af-a2c654b63866",
              "operator": {
                "type": "string",
                "operation": "notEmpty",
                "singleValue": true
              },
              "leftValue": "={{ $json[\"Scheduled Time\"] }}",
              "rightValue": "={{ $now }}"
            },
            {
              "id": "419fce85-1314-46f8-a06b-6984aae78644",
              "operator": {
                "type": "dateTime",
                "operation": "beforeOrEquals"
              },
              "leftValue": "={{ DateTime.fromFormat($json['Scheduled Time'], 'yyyy-MM-dd HH:mm', { zone: $('Configuration').first().json.timezone }) }}",
              "rightValue": "={{ $now.setZone($('Configuration').first().json.timezone) }}"
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "ad3ac0d7-f1d0-463b-a1ea-e3525589fbcb",
      "name": "Sticky Note18",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        400,
        -208
      ],
      "parameters": {
        "width": 272,
        "height": 192,
        "content": "### 4- Checks Schedule Time \n**Format:** \nYYYY-MM-DD HH:mm\neg: 2025-12-25 14:30\n\n**Important:** Set this column to **\"Plain Text\"** in Google Sheets to prevent errors.\n"
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "versionId": "2722c82f-10f2-4e34-945f-99aee6880228",
  "connections": {
    "If": {
      "main": [
        [
          {
            "node": "HTTP Download Image",
            "type": "main",
            "index": 0
          },
          {
            "node": "Attach Image Blob",
            "type": "main",
            "index": 1
          }
        ],
        [
          {
            "node": "Consolidate Streams",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Sort": {
      "main": [
        [
          {
            "node": "Loop Over Items",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter": {
      "main": [
        [
          {
            "node": "Sort",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create Post": {
      "main": [
        [
          {
            "node": "Update Thread State",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload Blob": {
      "main": [
        [
          {
            "node": "Attach Image Blob",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "BlueSky Auth": {
      "main": [
        [
          {
            "node": "Get row(s) in sheet",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Configuration": {
      "main": [
        [
          {
            "node": "BlueSky Auth",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Loop Over Items": {
      "main": [
        [],
        [
          {
            "node": "If",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Schedule Trigger": {
      "main": [
        [
          {
            "node": "Configuration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Attach Image Blob": {
      "main": [
        [
          {
            "node": "Consolidate Streams",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Construct Payload": {
      "main": [
        [
          {
            "node": "Create Post",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Consolidate Streams": {
      "main": [
        [
          {
            "node": "Construct Payload",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get row(s) in sheet": {
      "main": [
        [
          {
            "node": "Filter",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HTTP Download Image": {
      "main": [
        [
          {
            "node": "Upload Blob",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Update Thread State": {
      "main": [
        [
          {
            "node": "Update row in sheet",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Update row in sheet": {
      "main": [
        [
          {
            "node": "Loop Over Items",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}

Credentials you'll need

Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.

Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

This workflow turns a Google Sheet into a fully automated content calendar for BlueSky. It handles single posts, multi-post threads, and image attachments, allowing you to manage your entire social presence from a simple spreadsheet.

Source: https://n8n.io/workflows/12153/ — original creator credit. Request a take-down →

More Data & Sheets workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

Data & Sheets

This workflow automates video distribution to 9 social platforms simultaneously using Blotato's API. It includes both a scheduled publisher (checks Google Sheets for videos marked "Ready") and a subwo

Google Sheets, HTTP Request, Form Trigger +2
Data & Sheets

YogiAI. Uses googleSheets, googleSheetsTool, httpRequest, stopAndError. Scheduled trigger; 61 nodes.

Google Sheets, Google Sheets Tool, HTTP Request +1
Data & Sheets

This workflow monitors Google Calendar for events indicating that a customer will visit the company today or the next day, retrieves the required details, and sends reminder notifications to the relev

Google Calendar, Google Sheets, HTTP Request +1
Data & Sheets

Security teams, DevOps engineers, vulnerability analysts, and automation builders who want to eliminate repetitive Nessus scan parsing, AI-based risk triage, and manual reporting. Designed for orgs fo

Email Send, HTTP Request, Google Sheets +1
Data & Sheets

This workflow converts Google Sheets into a full Email CRM — no HubSpot or Customer.io needed! It automates segmentation, sending, validation, and delivery tracking using:

Google Sheets, HTTP Request