{
  "name": "Process Google Sheets rows with OpenAI in batches with retry and resume",
  "nodes": [
    {
      "parameters": {},
      "id": "09a454a7-0f10-5251-acd3-5027cce34d04",
      "name": "Run manually",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        -1500,
        0
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "minutes",
              "minutesInterval": 15
            }
          ]
        }
      },
      "id": "e7e571ae-de41-5ac7-ae97-71cc78b05528",
      "name": "Run every 15 minutes",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        -1500,
        200
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "811bfa48-a9f9-5732-b8c6-6a268008cf3e",
              "name": "sheet_url",
              "value": "PASTE_YOUR_GOOGLE_SHEET_URL_HERE",
              "type": "string"
            },
            {
              "id": "4a6b67ae-9857-5d98-8437-6a9384fe3110",
              "name": "sheet_tab",
              "value": "Sheet1",
              "type": "string"
            },
            {
              "id": "d681bb4e-a8ad-5227-80bf-1d51d560e02c",
              "name": "prompt_template",
              "value": "Summarize this in one short sentence:\n\n{input}",
              "type": "string"
            },
            {
              "id": "fe5f4f22-5f9a-5a2d-85dc-e476808c4f33",
              "name": "system_prompt",
              "value": "You are a careful assistant. Reply with the answer only.",
              "type": "string"
            },
            {
              "id": "9bbc0430-41b7-553f-bee9-869e94443730",
              "name": "output_format",
              "value": "text",
              "type": "string"
            },
            {
              "id": "1bcf46d3-741d-5d2e-a6e9-0027fa69bfc1",
              "name": "output_column",
              "value": "output",
              "type": "string"
            },
            {
              "id": "67e75b20-ee4f-5b01-a199-4bf15302025f",
              "name": "model",
              "value": "gpt-4.1-mini",
              "type": "string"
            },
            {
              "id": "f5c28901-b277-5414-815d-18f0206e492b",
              "name": "max_output_tokens",
              "value": 500,
              "type": "number"
            },
            {
              "id": "9f21552f-9146-5725-8482-4d9c8398e58f",
              "name": "rows_per_run",
              "value": 200,
              "type": "number"
            },
            {
              "id": "c05b8ad4-20a0-5fd6-b0fa-46c2b70b4cbf",
              "name": "batch_size",
              "value": 10,
              "type": "number"
            },
            {
              "id": "452d4d78-c95c-504d-b438-c3dd1105a06a",
              "name": "wait_between_batches_seconds",
              "value": 3,
              "type": "number"
            },
            {
              "id": "6ad53dfb-28e3-5c1f-9811-65814978c270",
              "name": "max_attempts",
              "value": 5,
              "type": "number"
            },
            {
              "id": "5257e227-69a1-5213-ae0d-e6581b077e50",
              "name": "base_backoff_seconds",
              "value": 2,
              "type": "number"
            },
            {
              "id": "1965465f-ac11-527f-9d77-3668c3b9b3a9",
              "name": "max_backoff_seconds",
              "value": 60,
              "type": "number"
            },
            {
              "id": "5831504e-ba22-52ac-b48b-a399b507f38c",
              "name": "lease_minutes",
              "value": 30,
              "type": "number"
            },
            {
              "id": "d2ae9fc3-4a9f-56a4-a52d-fe2433558865",
              "name": "request_timeout_seconds",
              "value": 60,
              "type": "number"
            },
            {
              "id": "0eac1544-2c55-5a16-8544-c5b094917b1b",
              "name": "api_url",
              "value": "https://api.openai.com/v1/chat/completions",
              "type": "string"
            },
            {
              "id": "413451bd-40c1-5dee-a863-8740a40ed049",
              "name": "notify_email",
              "value": "YOUR_EMAIL@example.com",
              "type": "string"
            }
          ]
        },
        "includeOtherFields": false,
        "options": {}
      },
      "id": "82d6ddcf-e8ac-5089-800c-26accd6806ed",
      "name": "Workflow configuration",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        -1260,
        100
      ]
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "mode": "url",
          "value": "={{ $('Workflow configuration').first().json.sheet_url }}"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $('Workflow configuration').first().json.sheet_tab }}"
        },
        "filtersUI": {},
        "options": {}
      },
      "id": "14208f30-3fcf-5b74-9b49-836ef570fc9e",
      "name": "Read all rows",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        -1020,
        100
      ],
      "alwaysOutputData": true,
      "retryOnFail": true,
      "maxTries": 5,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "jsCode": "// Picks the rows this run will work on and checks the sheet layout first, so a typo can't cause an endless loop.\n// Eligible: status empty or \"pending\", or \"processing\" left behind by a run that died more than lease_minutes ago.\nconst cfg = $('Workflow configuration').first().json;\nconst STATE = ['status', 'attempts', 'error_message', 'run_id', 'updated_at'];\nconst outCol = String(cfg.output_column || 'output').trim();\nconst rows = $input.all().map(i => i.json).filter(r => r && r.row_number !== undefined);\nconst now = Date.now(), nowIso = new Date(now).toISOString(), runId = String($execution.id);\nconst template = String(cfg.prompt_template || '');\nif (!rows.length) return [{ json: { _nothing: true, _reason: 'The sheet has no data rows.' } }];\n\nconst headers = new Set(rows.flatMap(r => Object.keys(r)));\nconst placeholders = [...new Set([...template.matchAll(/\\{([^{}\"\\n]+)\\}/g)].map(m => m[1]))].filter(p => headers.has(p));\nconst missing = [...STATE, outCol].filter(c => !headers.has(c));\nif (missing.length) throw new Error('Add these column headers to row 1 of the sheet: ' + missing.join(', '));\nif (!placeholders.length) throw new Error('prompt_template needs at least one {column} that matches a header, e.g. {' + [...headers].filter(h => h !== 'row_number')[0] + '}');\nconst fmt = String(cfg.output_format || 'text').toLowerCase();\nif (fmt === 'json' && !/json/i.test(template + ' ' + (cfg.system_prompt || ''))) throw new Error('output_format is json: OpenAI requires the word \"JSON\" in the prompt or system prompt.');\n\nconst fill = r => template.replace(/\\{([^{}\"\\n]+)\\}/g, (m, k) => placeholders.includes(k) ? String(r[k] ?? '') : m);\nconst lease = (Number(cfg.lease_minutes) || 30) * 60000;\nconst limit = Math.max(1, Number(cfg.rows_per_run) || 200);\nconst claim = [], empty = [];\nlet eligibleTotal = 0;\nfor (const r of rows) {\n  const st = String(r.status ?? '').trim().toLowerCase();\n  let eligible = st === '' || st === 'pending';\n  if (st === 'processing') { const t = Date.parse(r.updated_at); eligible = !(t && now - t < lease); }\n  if (!eligible) continue;\n  if (!placeholders.some(c => String(r[c] ?? '').trim() !== '')) {\n    if (empty.length < limit) empty.push({ row_number: r.row_number, status: 'error', attempts: 0, error_message: 'Input is empty. Fill it in and clear the status to run this row.', run_id: runId, updated_at: nowIso });\n    continue;\n  }\n  eligibleTotal++;\n  if (claim.length < limit) claim.push({ row_number: r.row_number, status: 'processing', attempts: 0, error_message: '', run_id: runId, updated_at: nowIso, _prompt: fill(r) });\n}\nif (!claim.length && !empty.length) return [{ json: { _nothing: true, _reason: 'No rows with an empty or pending status.' } }];\nconst meta = { _left_after_run: eligibleTotal - claim.length, _started_at: nowIso, _placeholders: placeholders };\nreturn [...claim, ...empty].map(j => ({ json: { ...j, ...meta } }));\n"
      },
      "id": "b3cae2d0-eac2-5ebc-a81c-dd2e6148516c",
      "name": "Select rows to process",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -800,
        100
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "fa546133-8ccb-50c8-87d2-d0bb66977d6e",
              "leftValue": "={{ $json._nothing }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "a8aceac1-9bea-515d-9713-4f927e93d7b0",
      "name": "Any rows to process?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        -580,
        100
      ]
    },
    {
      "parameters": {
        "operation": "update",
        "documentId": {
          "__rl": true,
          "mode": "url",
          "value": "={{ $('Workflow configuration').first().json.sheet_url }}"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $('Workflow configuration').first().json.sheet_tab }}"
        },
        "columns": {
          "mappingMode": "autoMapInputData",
          "value": {},
          "matchingColumns": [
            "row_number"
          ],
          "schema": []
        },
        "options": {
          "cellFormat": "RAW",
          "handlingExtraData": "ignoreIt"
        }
      },
      "id": "0319f9f9-ce57-5ea0-8813-92685a8eb9a7",
      "name": "Mark rows as processing",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        -340,
        0
      ],
      "retryOnFail": true,
      "maxTries": 5,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "jsCode": "// The queue = rows marked \"processing\" for this run. Rows already saved as done/error in earlier\n// loops are skipped; rows waiting for a retry go first. Results live in \"Sort API results\" (one run per loop).\nconst cfg = $('Workflow configuration').first().json;\nconst queue = $('Select rows to process').all().map(i => i.json).filter(r => r.status === 'processing');\nconst last = {};\nfor (let r = 0; r < $runIndex; r++) for (const it of $('Sort API results').all(0, r)) last[it.json.row_number] = it.json;\nconst retry = [], fresh = [];\nfor (const q of queue) {\n  const s = last[q.row_number];\n  if (!s) fresh.push({ ...q, attempts: 0 });\n  else if (s.status === 'processing') retry.push({ ...q, attempts: Number(s.attempts) || 0 });\n}\nconst size = Math.max(1, Number(cfg.batch_size) || 10);\nconst sys = String(cfg.system_prompt || '').trim();\nconst all = retry.concat(fresh), rest = all.slice(size).map(q => q.row_number);\nreturn all.slice(0, size).map((q, i) => {\n  const body = { model: cfg.model, messages: [...(sys ? [{ role: 'system', content: sys }] : []), { role: 'user', content: q._prompt }],\n                 max_completion_tokens: Number(cfg.max_output_tokens) || 500 };\n  if (String(cfg.output_format).toLowerCase() === 'json') body.response_format = { type: 'json_object' };\n  return { json: { row_number: q.row_number, attempts: q.attempts, request: body, ...(i === 0 ? { _rest: rest } : {}) } };\n});\n"
      },
      "id": "231cecd2-3598-52d9-b1bb-23d3df98c35d",
      "name": "Pick next batch",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -100,
        0
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $('Workflow configuration').first().json.api_url }}",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "openAiApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.request) }}",
        "options": {
          "timeout": "={{ ($('Workflow configuration').first().json.request_timeout_seconds || 60) * 1000 }}",
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true,
              "responseFormat": "autodetect"
            }
          }
        }
      },
      "id": "852d4fde-9960-55b0-9322-5c5a14a33980",
      "name": "Call OpenAI chat completions",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        120,
        0
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "// Turns each API answer into the row update: done, error, or retry later (status stays \"processing\").\n// 429/5xx/timeouts wait for Retry-After (or OpenAI's reset headers), else exponential backoff with jitter.\n// Bad key, missing model or empty billing stop the whole run instead of failing every row.\nconst cfg = $('Workflow configuration').first().json;\nconst sel = $('Select rows to process').all().map(i => i.json);\nconst batch = $('Pick next batch').all().map(i => i.json);\nconst now = new Date().toISOString(), runId = String($execution.id);\nconst MAX = Math.max(1, Number(cfg.max_attempts) || 5), BASE = Number(cfg.base_backoff_seconds) || 2, CAP = Number(cfg.max_backoff_seconds) || 60;\nconst outCol = String(cfg.output_column || 'output').trim();\nconst json = String(cfg.output_format || 'text').toLowerCase() === 'json';\nconst reserved = new Set(['row_number', 'status', 'attempts', 'error_message', 'run_id', 'updated_at', outCol, ...(sel[0]?._placeholders || [])]);\nconst dur = s => { let t = 0, m, re = /([\\d.]+)(ms|s|m|h)/g; while ((m = re.exec(String(s)))) t += parseFloat(m[1]) * { ms: .001, s: 1, m: 60, h: 3600 }[m[2]]; return t; };\nconst serverWait = h => {\n  if (h['retry-after-ms']) return Number(h['retry-after-ms']) / 1000;\n  const ra = h['retry-after'];\n  if (ra !== undefined && ra !== '') return isNaN(Number(ra)) ? Math.max(0, (Date.parse(ra) - Date.now()) / 1000) : Number(ra);\n  const r = [['remaining-requests', 'reset-requests'], ['remaining-tokens', 'reset-tokens']]\n    .filter(([a]) => String(h['x-ratelimit-' + a]) === '0').map(([, b]) => dur(h['x-ratelimit-' + b]));\n  return r.length ? Math.max(...r) : null;\n};\nconst errText = b => (b && typeof b === 'object' && b.error) ? (b.error.message || JSON.stringify(b.error)) : String(b ?? '').slice(0, 300);\nlet wait = Number(cfg.wait_between_batches_seconds) || 0, fatal = '';\nconst results = $input.all().map((item, i) => {\n  const src = batch[item.pairedItem?.item ?? i] || batch[i];\n  const attempts = (Number(src.attempts) || 0) + 1;\n  const r = item.json || {}, code = Number(r.statusCode) || 0, h = r.headers || {}, b = r.body;\n  const row = { row_number: src.row_number, attempts, run_id: runId, updated_at: now };\n  const retry = (why, w) => {\n    if (attempts >= MAX) return { ...row, status: 'error', error_message: `Gave up after ${attempts} attempts. Last: ${why}` };\n    wait = Math.max(wait, w ?? Math.min(CAP, BASE * 2 ** (attempts - 1)) * (0.8 + Math.random() * 0.4));\n    return { ...row, status: 'processing', error_message: `Attempt ${attempts}: ${why}. Retrying.` };\n  };\n  if (!code) return retry('No response (' + String(r.error?.message || r.error || 'network error').slice(0, 200) + ')');\n  if (code === 401 || code === 403 || code === 404 || (code === 429 && /insufficient_quota/.test(JSON.stringify(b || '')))) {\n    fatal = fatal || `OpenAI answered HTTP ${code}: ${errText(b)}`;\n    return { ...row, attempts: attempts - 1, status: 'pending', error_message: 'Not processed: run stopped (' + `HTTP ${code}` + ').' };\n  }\n  if (code === 429 || code === 408 || code === 409 || code >= 500) {\n    const w = serverWait(h);\n    return retry(`HTTP ${code} ${errText(b).slice(0, 150)}`, w === null ? undefined : Math.min(w, 900));\n  }\n  if (code < 200 || code >= 300) return { ...row, status: 'error', error_message: `HTTP ${code}: ${errText(b)}` };\n  const choice = (b && typeof b === 'object' && Array.isArray(b.choices)) ? b.choices[0] : null;\n  const content = choice?.message?.content;\n  if (choice?.finish_reason === 'length') return { ...row, status: 'error', error_message: 'Answer was cut off at max_output_tokens. Raise it and clear the status.' };\n  if (choice?.message?.refusal) return { ...row, status: 'error', error_message: 'Model refused: ' + String(choice.message.refusal).slice(0, 200) };\n  if (typeof content !== 'string' || !content.trim()) return attempts < 2 ? retry('Empty or malformed API answer', BASE) : { ...row, status: 'error', error_message: 'Empty or malformed API answer: ' + JSON.stringify(b).slice(0, 200) };\n  if (!json) return { ...row, status: 'done', error_message: '', [outCol]: content.trim() };\n  const txt = content.trim().replace(/^```(?:json)?\\s*|\\s*```$/g, '');\n  let parsed;\n  try { parsed = JSON.parse(txt); } catch (e) {\n    return attempts < 2 ? retry('Model output is not valid JSON', BASE) : { ...row, status: 'error', error_message: 'Model output is not valid JSON: ' + txt.slice(0, 200) };\n  }\n  const extra = {};\n  if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) for (const [k, v] of Object.entries(parsed)) if (!reserved.has(k) && !k.startsWith('_')) extra[k] = v;\n  return { ...extra, ...row, status: 'done', error_message: '', [outCol]: JSON.stringify(parsed) };\n});\nconst rest = batch[0]?._rest || [];   // rows of this run not in this batch and not finished yet\nconst open = rest.length + results.filter(r => r.status === 'processing').length;\nif (fatal) {\n  // Hand every unfinished row of this run back as \"pending\" in the same write, so a fixed key can rerun them at once.\n  results.forEach(r => { if (r.status === 'processing') Object.assign(r, { status: 'pending', error_message: 'Not processed: run stopped.' }); });\n  for (const n of rest) results.push({ row_number: n, status: 'pending', attempts: 0, error_message: 'Not processed: run stopped.', run_id: '', updated_at: now });\n}\nconst next = fatal ? 'stop' : (open ? 'more' : 'finished');\nreturn results.map(r => ({ json: { ...r, _next: next, _fatal: fatal, _wait_seconds: Math.round(Math.max(1, wait) * 10) / 10 } }));\n"
      },
      "id": "3fd12a87-22d5-5816-8ef5-bdd3a42f5fbd",
      "name": "Sort API results",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        340,
        0
      ]
    },
    {
      "parameters": {
        "operation": "update",
        "documentId": {
          "__rl": true,
          "mode": "url",
          "value": "={{ $('Workflow configuration').first().json.sheet_url }}"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $('Workflow configuration').first().json.sheet_tab }}"
        },
        "columns": {
          "mappingMode": "autoMapInputData",
          "value": {},
          "matchingColumns": [
            "row_number"
          ],
          "schema": []
        },
        "options": {
          "cellFormat": "RAW",
          "handlingExtraData": "ignoreIt"
        }
      },
      "id": "01a14a82-87c7-59e2-afaf-2e875804d35e",
      "name": "Save batch results",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        560,
        0
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "902f4876-f6dc-506d-96ef-89b4a850e4e8",
              "leftValue": "={{ !$json.error }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "5c8330b3-0720-53c3-a677-c08956af9958",
      "name": "Saved to sheet?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        800,
        0
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "d8088754-1cbf-5b08-8bd7-ed9b4077ffdb",
                    "leftValue": "={{ $json._next }}",
                    "rightValue": "more",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "More rows"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "0bcaa886-a350-5287-869f-088818a76705",
                    "leftValue": "={{ $json._next }}",
                    "rightValue": "finished",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Finished"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "7ef86cf6-4577-56f6-b435-bc13faa3b34a",
                    "leftValue": "={{ $json._next }}",
                    "rightValue": "stop",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Stopped"
            }
          ]
        },
        "options": {}
      },
      "id": "fbb8b995-8760-55dc-83fa-f543451ab1a6",
      "name": "What next?",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        1100,
        0
      ]
    },
    {
      "parameters": {
        "amount": "={{ $json._wait_seconds }}",
        "unit": "seconds"
      },
      "id": "e8fdd53b-b415-5de8-995c-3ce46f99c7a4",
      "name": "Wait between batches",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1.1,
      "position": [
        1340,
        -200
      ],
      "webhookId": "74ef29f7-9823-52c8-b4e9-c532fadf4f33"
    },
    {
      "parameters": {
        "jsCode": "// Google Sheets refused the write (usually 429: per-minute quota). Retry the same rows after\n// 5, 15, 30 and 60 seconds. If the 5th write fails too, stop: the rows stay \"processing\" and\n// rerun after lease_minutes. Nothing is sent to OpenAI again while we wait.\nconst WAITS = [5, 15, 30, 60];\nconst rows = $('Sort API results').all().map(i => i.json);\nconst key = `${rows[0]?.row_number}-${rows[rows.length - 1]?.row_number}-${rows.length}`;\nconst [lastKey, lastCount] = String($execution.customData.get('sheets_retry') || '').split('|');\nconst failures = (lastKey === key ? Number(lastCount) : 0) + 1;\n$execution.customData.set('sheets_retry', `${key}|${failures}`);\nconst e = $json.error;\nconst msg = String((e && typeof e === 'object' ? e.message : e) || 'unknown error').slice(0, 300);\nif (failures > WAITS.length) {\n  return [{ json: { _retry: false, _next: 'stop', _fatal: `Google Sheets write failed ${failures} times (${msg}). Results for rows ${rows.map(r => r.row_number).join(', ')} were not saved; they rerun after lease_minutes.` } }];\n}\nreturn rows.map(r => ({ json: { ...r, _retry: true, _sheet_wait: WAITS[failures - 1] } }));\n"
      },
      "id": "8d9b87be-39cf-5c02-ad45-7c9d46abf08a",
      "name": "Plan Sheets retry",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        580,
        320
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "3fcdd419-694d-5798-b502-1ae539f07b5f",
              "leftValue": "={{ $json._retry }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "8a86a623-2526-5099-b625-2862b911dbe5",
      "name": "Retry Sheets write?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        800,
        320
      ]
    },
    {
      "parameters": {
        "amount": "={{ $json._sheet_wait }}",
        "unit": "seconds"
      },
      "id": "6f765a17-6f95-556f-8555-e074033cc297",
      "name": "Wait for Sheets quota",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1.1,
      "position": [
        1020,
        320
      ],
      "webhookId": "9eaae53c-16c9-5782-a5b0-db526917738e"
    },
    {
      "parameters": {
        "jsCode": "// Counts the final state of every row this run touched. No email when there was nothing to do.\nconst cfg = $('Workflow configuration').first().json;\nconst sel = $('Select rows to process').isExecuted ? $('Select rows to process').all().map(i => i.json) : [];\nconst first = sel[0] || {};\nconst final = {};\nfor (const r of sel) if (r.row_number !== undefined) final[r.row_number] = r;\n// Only batches that Google Sheets accepted count; rows from a failed write are still \"processing\".\nif ($('Saved to sheet?').isExecuted) {\n  for (let r = 0; r < 100000; r++) {\n    let ok, failed; try { ok = $('Saved to sheet?').all(0, r); failed = $('Saved to sheet?').all(1, r); } catch (e) { break; }\n    if (!ok?.length && !failed?.length) break;\n    for (const it of ok || []) if (it.json.row_number !== undefined) final[it.json.row_number] = it.json;\n  }\n}\nconst rows = Object.values(final);\nconst count = s => rows.filter(r => r.status === s).length;\nconst done = count('done'), errors = rows.filter(r => r.status === 'error'), released = count('pending'), stuck = count('processing');\nconst fatal = $json._fatal || '';\nconst left = (Number(first._left_after_run) || 0) + released + stuck;\nconst mins = first._started_at ? ((Date.now() - Date.parse(first._started_at)) / 60000).toFixed(1) : '0';\nconst lines = [\n  fatal ? 'STOPPED: ' + fatal : 'Run finished.',\n  `Done: ${done}`, `Errors: ${errors.length}`, `Still to do in the sheet: ${left}`, `Time: ${mins} min`, '',\n  ...errors.slice(0, 15).map(r => `Row ${r.row_number}: ${r.error_message}`),\n  errors.length > 15 ? `...and ${errors.length - 15} more (filter the status column for \"error\").` : '',\n];\nreturn [{ json: {\n  send: Boolean(fatal) || rows.length > 0,\n  subject: (fatal ? 'Sheet AI run stopped' : 'Sheet AI run') + `: ${done} done, ${errors.length} errors, ${left} left`,\n  body: lines.join('\\n').trim(), done, errors: errors.length, left, fatal,\n} }];\n"
      },
      "id": "1c336fde-cf38-5bfc-a9e2-46442163929c",
      "name": "Build run summary",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1600,
        60
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "dde26ee3-ae0e-5da3-8a23-0365e2f14137",
              "leftValue": "={{ $json.send }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "de55799a-7b70-57d4-b71a-2882eb6819d8",
      "name": "Anything to report?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1820,
        60
      ]
    },
    {
      "parameters": {
        "sendTo": "={{ $('Workflow configuration').first().json.notify_email }}",
        "subject": "={{ $json.subject }}",
        "emailType": "text",
        "message": "={{ $json.body }}",
        "options": {
          "appendAttribution": false
        }
      },
      "id": "6cc1e8ac-9bc6-5d97-8639-d3c8706390a2",
      "name": "Email run summary",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        2040,
        60
      ]
    },
    {
      "parameters": {
        "content": "### How it works\nRuns one AI prompt over every row of a Google Sheet without stalling on `429 Too Many Requests`, and picks up where it stopped.\n1. Reads the sheet and picks rows whose `status` is empty or `pending` (up to `rows_per_run`). It marks them `processing` in one write, so overlapping runs skip them.\n2. Sends them to OpenAI in batches of `batch_size`, with a pause between batches.\n3. A 429 or 5xx is retried after the `Retry-After` time OpenAI sends, or with exponential backoff. After `max_attempts` the row gets status `error` and the reason in `error_message`.\n4. Each batch is saved in one Sheets write. If Sheets answers 429, that write is retried after 5, 15, 30 and 60 s. Rows marked `done` are never sent again.\n5. A bad API key or empty billing stops the run, puts unfinished rows back to `pending` and emails you. You also get a summary at the end.\n\n### Setup\n1. Add a header row with your input column(s) plus `output, status, attempts, error_message, run_id, updated_at`. Keep everything within columns A–Z.\n2. Create an **OpenAI** credential and select it in **Call OpenAI chat completions**.\n3. Connect Google Sheets in the three Sheets nodes and Gmail in **Email run summary**.\n4. In **Workflow configuration**, fill in `sheet_url`, `sheet_tab`, `notify_email` and `prompt_template`. Columns go in braces, like `{input}`.\n5. Click **Execute workflow**. For big sheets, publish so **Run every 15 minutes** takes the next chunk each time.\n\n### Customization\nSet `output_format` to `json` (and ask for JSON in the prompt). JSON keys that match a column header are then written to that column too. Change the model, batch size, pauses and retry limits in **Workflow configuration**. To retry an `error` row, clear its status.",
        "width": 700,
        "height": 1100,
        "color": 1
      },
      "id": "80fd60d1-c567-5b90-8e71-22c5e147c9cf",
      "name": "Main Description",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -2260,
        -400
      ]
    },
    {
      "parameters": {
        "content": "## Start and settings\nRun it by hand, or publish to take the next chunk every 15 minutes. Every setting is in **Workflow configuration**.",
        "width": 460,
        "height": 520,
        "color": 7
      },
      "id": "131abdd9-e481-568c-bef9-f0005f8ac510",
      "name": "Section 1",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -1560,
        -160
      ]
    },
    {
      "parameters": {
        "content": "## Pick and claim rows\nChecks the headers, picks empty or pending rows, and marks them `processing` in one write.",
        "width": 860,
        "height": 520,
        "color": 7
      },
      "id": "bb02a9ba-9587-578b-b9a0-c4d17e89f176",
      "name": "Section 2",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -1060,
        -160
      ]
    },
    {
      "parameters": {
        "content": "## Batches with retry\nOne batch at a time. 429s and 5xx wait for Retry-After or back off, then try again. Each batch is saved in one write.",
        "width": 1640,
        "height": 460,
        "color": 7
      },
      "id": "661c0c8e-3715-5aac-bd38-00b1be26d92d",
      "name": "Section 3",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -160,
        -280
      ]
    },
    {
      "parameters": {
        "content": "## Sheets quota retry\nIf Google Sheets refuses a write (429 quota), save the same rows again after 5, 15, 30 and 60 s. Then stop and say so in the email.",
        "width": 680,
        "height": 260,
        "color": 7
      },
      "id": "37258c0d-6af4-5f38-aed5-1f52b853ceab",
      "name": "Section 4",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        520,
        220
      ]
    },
    {
      "parameters": {
        "content": "## Summary\nOne email per run with done, error and remaining counts. It says why if the run stopped.",
        "width": 700,
        "height": 280,
        "color": 7
      },
      "id": "c6e4e49c-0a30-5a10-99d5-3883a57b3594",
      "name": "Section 5",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1540,
        -100
      ]
    }
  ],
  "connections": {
    "Run manually": {
      "main": [
        [
          {
            "node": "Workflow configuration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run every 15 minutes": {
      "main": [
        [
          {
            "node": "Workflow configuration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Workflow configuration": {
      "main": [
        [
          {
            "node": "Read all rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read all rows": {
      "main": [
        [
          {
            "node": "Select rows to process",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Select rows to process": {
      "main": [
        [
          {
            "node": "Any rows to process?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Any rows to process?": {
      "main": [
        [
          {
            "node": "Build run summary",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Mark rows as processing",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mark rows as processing": {
      "main": [
        [
          {
            "node": "Pick next batch",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick next batch": {
      "main": [
        [
          {
            "node": "Call OpenAI chat completions",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Call OpenAI chat completions": {
      "main": [
        [
          {
            "node": "Sort API results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Sort API results": {
      "main": [
        [
          {
            "node": "Save batch results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save batch results": {
      "main": [
        [
          {
            "node": "Saved to sheet?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Saved to sheet?": {
      "main": [
        [
          {
            "node": "What next?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Plan Sheets retry",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "What next?": {
      "main": [
        [
          {
            "node": "Wait between batches",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build run summary",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build run summary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait between batches": {
      "main": [
        [
          {
            "node": "Pick next batch",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Plan Sheets retry": {
      "main": [
        [
          {
            "node": "Retry Sheets write?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Retry Sheets write?": {
      "main": [
        [
          {
            "node": "Wait for Sheets quota",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "What next?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait for Sheets quota": {
      "main": [
        [
          {
            "node": "Save batch results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build run summary": {
      "main": [
        [
          {
            "node": "Anything to report?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Anything to report?": {
      "main": [
        [
          {
            "node": "Email run summary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "tags": []
}