{
  "name": "Process a large list in AI batches with Split In Batches and HTTP Request",
  "nodes": [
    {
      "parameters": {
        "content": "## Process a large list in AI batches\n\nSends many rows per AI call instead of one call per row, and keeps going when a batch fails.\n\n### Who's it for\nAnyone pushing a few thousand rows through a model: classification, tagging, summarising, enrichment. The naive one-call-per-row build works fine on ten rows and becomes unusable on ten thousand.\n\n### How it works\n- **Chunk the list** splits the input into batches of a size you choose.\n- **Build one request** packs a whole chunk into a single prompt, so the system prompt is billed once per chunk instead of once per row.\n- **Classify the chunk** calls the model. It never throws and retries twice on its own.\n- **Chunk succeeded?** sends good chunks to **Split back to rows**, which restores one item per row with its chunk index attached.\n- A failed chunk goes to **Record the failure** and the loop carries on. **Merge the results** reports how many rows returned and which chunks need another pass.\n\n### Setup\n1. Replace **Load the list** with your real source: a Google Sheet, a Postgres query, an HTTP call. It only has to emit one item per row.\n2. Open **Chunk the list** and set the batch size. Ten to twenty-five is a sane start for short rows.\n3. Open **Classify the chunk** and attach your credential under Authentication, Generic, Header Auth.\n\n### Requirements\nAn API credential for any chat completion endpoint. No community nodes.\n\n### How to customize\nBigger chunks mean fewer calls and lower cost. Smaller chunks mean less lost work when one fails, and less risk of hitting a context limit.\n\nFull walkthrough: https://www.theagentecosystem.com/blog/n8n-batch-processing-ai",
        "height": 1036,
        "width": 760,
        "color": 1
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -40,
        -786
      ],
      "name": "Note: Overview"
    },
    {
      "parameters": {
        "content": "## 1. Load and chunk\nSwap in your real source. Batch size is the one number worth tuning here.",
        "height": 260,
        "width": 700,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -40,
        490
      ],
      "name": "Note: Load and chunk"
    },
    {
      "parameters": {
        "content": "## 2. One call per chunk\nPacks a whole chunk into a single prompt, so the system prompt is billed once per chunk.",
        "height": 260,
        "width": 720,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        660,
        610
      ],
      "name": "Note: One call per chunk"
    },
    {
      "parameters": {
        "content": "## 3. Keep the chunk index\nGood chunks return to one item per row. A failure records which chunk to re-run.",
        "height": 500,
        "width": 240,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1380,
        490
      ],
      "name": "Note: Keep the chunk index"
    },
    {
      "parameters": {
        "content": "## 4. Summarise the run\nRows returned and chunks still owed, instead of stopping at the first bad batch.",
        "height": 260,
        "width": 480,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1620,
        370
      ],
      "name": "Note: Summarise the run"
    },
    {
      "parameters": {},
      "id": "b1c2d3e4-0000-4000-8000-000000000003",
      "name": "Run the job",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        640
      ]
    },
    {
      "parameters": {
        "jsCode": "// Stand-in for your real source. Swap this node for a Google Sheets read, a\n// Postgres query, or an HTTP call — anything that emits one item per row.\n//\n// Twelve rows at a batch size of 5 gives three chunks, which is enough to see\n// the chunking, the failure branch, and the summary all do their jobs.\nconst rows = [\n  'Refund never arrived, third email about this',\n  'How do I change the card on file?',\n  'Love the new dashboard',\n  'Charged twice this month',\n  'Can you export to CSV yet?',\n  'Cancel my account',\n  'The mobile app crashes on upload',\n  'Do you have a student discount?',\n  'Invoice has the wrong VAT number',\n  'Great support from Sam yesterday',\n  'API returns 500 on the search endpoint',\n  'Renewal price went up without warning',\n];\n\nreturn rows.map((text, i) => ({ json: { rowId: i + 1, text } }));"
      },
      "id": "b1c2d3e4-0000-4000-8000-000000000004",
      "name": "Load the list",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        220,
        640
      ]
    },
    {
      "parameters": {
        "batchSize": 5,
        "options": {}
      },
      "id": "b1c2d3e4-0000-4000-8000-000000000005",
      "name": "Chunk the list",
      "type": "n8n-nodes-base.splitInBatches",
      "typeVersion": 3,
      "position": [
        460,
        640
      ]
    },
    {
      "parameters": {
        "jsCode": "// Fold this chunk's rows into ONE request. This is the whole point of the\n// workflow: 12 rows at a batch size of 5 costs 3 calls instead of 12, and the\n// system prompt is billed once per chunk rather than once per row.\n//\n// The chunk index travels with the payload so a failure later can name exactly\n// which rows need re-running.\nconst rows = $input.all().map((i) => i.json);\nconst state = $getWorkflowStaticData('global');\nstate.chunkIndex = (state.chunkIndex || 0) + 1;\n\nconst numbered = rows.map((r, i) => `${i + 1}. ${r.text}`).join('\\n');\n\nreturn [{\n  json: {\n    chunkIndex: state.chunkIndex,\n    rowIds: rows.map((r) => r.rowId),\n    rowCount: rows.length,\n    endpoint: 'https://api.openai.com/v1/chat/completions',\n    model: 'gpt-4o-mini',\n    prompt: `Classify each message as billing, bug, feature_request, cancellation or praise.\\nReturn ONLY a JSON array of objects with keys \"n\" and \"label\", one per message, in order.\\n\\n${numbered}`,\n  },\n}];"
      },
      "id": "b1c2d3e4-0000-4000-8000-000000000006",
      "name": "Build one request",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        700,
        760
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $json.endpoint }}",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ model: $json.model, messages: [{ role: 'user', content: $json.prompt }] }) }}",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true
            }
          }
        }
      },
      "id": "b1c2d3e4-0000-4000-8000-000000000007",
      "name": "Classify the chunk",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        940,
        760
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 3000,
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "b1c2d3e4-0000-4000-8000-000000000010",
              "leftValue": "={{ $json.statusCode }}",
              "rightValue": 300,
              "operator": {
                "type": "number",
                "operation": "lt"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "b1c2d3e4-0000-4000-8000-000000000008",
      "name": "Chunk succeeded?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1180,
        760
      ]
    },
    {
      "parameters": {
        "jsCode": "// Pull the labels back out and re-attach them to the row ids we sent, so the\n// result is per-row again rather than per-chunk. If the model returned\n// something that is not the JSON array we asked for, that counts as a chunk\n// failure — a silently mangled result is worse than a loud one.\nconst req = $('Build one request').first().json;\nconst body = $input.first().json.body;\nconst raw = body?.choices?.[0]?.message?.content ?? '';\n\nlet labels;\ntry {\n  labels = JSON.parse(raw.replace(/^```(?:json)?|```$/g, '').trim());\n} catch {\n  labels = null;\n}\n\nif (!Array.isArray(labels) || labels.length !== req.rowCount) {\n  return [{\n    json: {\n      ok: false,\n      chunkIndex: req.chunkIndex,\n      rowIds: req.rowIds,\n      reason: 'model did not return one label per row',\n    },\n  }];\n}\n\nreturn labels.map((l, i) => ({\n  json: {\n    ok: true,\n    chunkIndex: req.chunkIndex,\n    rowId: req.rowIds[i],\n    label: l.label ?? null,\n  },\n}));"
      },
      "id": "b1c2d3e4-0000-4000-8000-000000000009",
      "name": "Split back to rows",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1420,
        640
      ]
    },
    {
      "parameters": {
        "jsCode": "// Keep the chunk, do not lose it. The index and the row ids are what let you\n// re-run just this chunk instead of the whole job.\nconst req = $('Build one request').first().json;\nconst res = $input.first().json;\n\nreturn [{\n  json: {\n    ok: false,\n    chunkIndex: req.chunkIndex,\n    rowIds: req.rowIds,\n    statusCode: res.statusCode ?? null,\n    reason: res.error?.message || `HTTP ${res.statusCode ?? 'error'} after retries`,\n  },\n}];"
      },
      "id": "b1c2d3e4-0000-4000-8000-00000000000a",
      "name": "Record the failure",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1420,
        880
      ]
    },
    {
      "parameters": {
        "jsCode": "// Every chunk has been through the loop. Report rows that came back and the\n// chunks that need another pass, so a partial run is actionable rather than\n// just \"something went wrong\".\nconst items = $input.all().map((i) => i.json);\nconst rows = items.filter((i) => i.ok);\nconst failed = items.filter((i) => !i.ok);\n\nconst state = $getWorkflowStaticData('global');\nstate.chunkIndex = 0;\n\nreturn [{\n  json: {\n    rowsClassified: rows.length,\n    chunksFailed: failed.length,\n    retryChunks: failed.map((f) => ({ chunkIndex: f.chunkIndex, rowIds: f.rowIds, reason: f.reason })),\n    labels: rows.map((r) => ({ rowId: r.rowId, label: r.label })),\n  },\n}];"
      },
      "id": "b1c2d3e4-0000-4000-8000-00000000000b",
      "name": "Merge the results",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1660,
        520
      ]
    },
    {
      "parameters": {},
      "id": "b1c2d3e4-0000-4000-8000-00000000000c",
      "name": "Finished",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1900,
        520
      ]
    }
  ],
  "connections": {
    "Run the job": {
      "main": [
        [
          {
            "node": "Load the list",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load the list": {
      "main": [
        [
          {
            "node": "Chunk the list",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Chunk the list": {
      "main": [
        [
          {
            "node": "Merge the results",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build one request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build one request": {
      "main": [
        [
          {
            "node": "Classify the chunk",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Classify the chunk": {
      "main": [
        [
          {
            "node": "Chunk succeeded?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Chunk succeeded?": {
      "main": [
        [
          {
            "node": "Split back to rows",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Record the failure",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split back to rows": {
      "main": [
        [
          {
            "node": "Chunk the list",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Record the failure": {
      "main": [
        [
          {
            "node": "Chunk the list",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge the results": {
      "main": [
        [
          {
            "node": "Finished",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "active": false,
  "tags": []
}
