{ "name": "Chat with your CSV and Excel data using Claude and a free Cradler database", "nodes": [ { "parameters": { "content": "## Chat with your CSV and Excel data, no database to set up\n\nUpload a CSV or Excel file and ask questions about it in plain words: \"How many orders are unpaid?\", \"Which category had the most refunds?\", \"Revenue from paid orders in September?\". The file goes into a free [Cradler](https://cradler.ai) database, which creates the table and columns on the first write, and an AI agent running Claude through [apimodels.app](https://apimodels.app) looks up the table, queries it and does the math.\n\n### How it works\n1. **Upload form** takes the file and a table name. Headers become snake_case columns and each column gets one type (number, true/false or text).\n2. Rows are inserted in batches of 500, and the table is recorded in a `data_tables` list.\n3. In the **Chat**, the agent lists tables, reads a table's columns, then runs read-only queries with filters, sorting and exact counts, and uses the calculator for sums and averages.\n\n### Setup\n1. Create a free project at [cradler.ai](https://cradler.ai) and add its **service** key as a **Bearer Auth** credential named \"Cradler service key\".\n2. Create an API key in the [apimodels console](https://apimodels.app/console/api-keys) and add an **OpenAI** credential with Base URL `https://api.apimodels.app/v1`.\n3. In **Settings**, replace `YOUR_PROJECT_ID` with your Cradler project id.\n4. Activate, upload a file through the form, then open the chat from the **Chat** node.\nStep-by-step guide: [apimodels.app/docs/n8n](https://apimodels.app/docs/n8n)\n\n### Good to know\n- One query returns at most 1,000 rows, so sums and averages are exact up to that many matching rows; counts are always exact. Best for files up to a few thousand rows.\n- The chat tools can only read data. Keep both forms private or behind Basic Auth.", "height": 980, "width": 560, "color": 1 }, "id": "1b9c9764-1cb0-5414-b9f5-992a672dc1f2", "name": "Overview", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ -700, -380 ] }, { "parameters": { "content": "## 1. Import a file\nReads CSV or Excel, cleans headers and types, inserts in batches and records the table.", "height": 380, "width": 1580, "color": 7 }, "id": "8ffb7b39-4e32-5469-903c-73f0ad256f29", "name": "Section: import", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ 380, -200 ] }, { "parameters": { "content": "## 2. Chat with the data\nThe agent lists tables, reads columns, runs read-only queries and calculates.", "height": 560, "width": 1000, "color": 7 }, "id": "8d4fd513-fc58-58dd-90c9-281927bb41f7", "name": "Section: chat", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ 380, 260 ] }, { "parameters": { "formTitle": "Upload data to chat with", "formDescription": "Upload a CSV or Excel file. The first row must contain the column names.", "formFields": { "values": [ { "fieldLabel": "Data file", "fieldType": "file", "multipleFiles": false, "acceptFileTypes": ".csv, .xlsx, .xls", "requiredField": true }, { "fieldLabel": "Table name", "requiredField": true, "placeholder": "e.g. orders" } ] }, "options": { "path": "upload-data", "buttonLabel": "Import" } }, "id": "6104a26b-48aa-5263-b681-a02e1eabc3d1", "name": "Upload form", "type": "n8n-nodes-base.formTrigger", "typeVersion": 2.2, "position": [ 0, 0 ], "webhookId": "b8807542-3cf1-596f-a230-50f9fbb4692e" }, { "parameters": { "public": true, "mode": "hostedChat", "initialMessages": "Hi! Ask me anything about the data you uploaded.\nFor example: \"How many orders are unpaid?\"", "options": { "title": "Chat with your data", "subtitle": "Read-only answers from your Cradler tables" } }, "id": "ec763d6d-9d5e-529c-8623-b93ff747665b", "name": "Chat", "type": "@n8n/n8n-nodes-langchain.chatTrigger", "typeVersion": 1.1, "position": [ 0, 420 ], "webhookId": "3ae67ead-1c72-5688-a063-7ccf9f61a155" }, { "parameters": { "assignments": { "assignments": [ { "id": "45258ddf-6cae-5bca-b42f-ec49c908e9b5", "name": "cradler_api", "value": "https://gateway.cradler.ai/v1/YOUR_PROJECT_ID", "type": "string" }, { "id": "20280696-5b9a-5313-8831-61141d1611d8", "name": "batch_size", "value": 500, "type": "number" } ] }, "includeOtherFields": true, "options": { "stripBinary": false } }, "id": "f8b6b32f-f117-5b95-bbaa-0b46214efa61", "name": "Settings", "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [ 220, 200 ] }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "7cddae55-5b86-5876-aa7f-65611b3c5561", "leftValue": "={{ $json.chatInput !== undefined }}", "operator": { "type": "boolean", "operation": "true", "singleValue": true }, "rightValue": "" } ], "combinator": "and" }, "options": {} }, "id": "e5bc89ba-626d-5dce-abbf-00160751f7d8", "name": "Chat message?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ 440, 200 ] }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "dcc9a2c5-5412-596e-afdb-cb3d55d38837", "leftValue": "={{ /\\.xlsx?$/i.test($binary.Data_file?.fileName || '') }}", "operator": { "type": "boolean", "operation": "true", "singleValue": true }, "rightValue": "" } ], "combinator": "and" }, "options": {} }, "id": "7a3610ab-c823-57d0-9028-2c0f3d9aec7d", "name": "Excel file?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ 660, 0 ] }, { "parameters": { "operation": "xlsx", "binaryPropertyName": "Data_file", "options": {} }, "id": "7f45e16f-1db4-52f9-bd10-5668b085c36f", "name": "Read Excel", "type": "n8n-nodes-base.extractFromFile", "typeVersion": 1, "position": [ 880, -100 ], "onError": "continueErrorOutput" }, { "parameters": { "operation": "csv", "binaryPropertyName": "Data_file", "options": {} }, "id": "f4eb1b61-4096-59fa-8bbe-2fc80d4dec42", "name": "Read CSV", "type": "n8n-nodes-base.extractFromFile", "typeVersion": 1, "position": [ 880, 100 ], "onError": "continueErrorOutput" }, { "parameters": { "jsCode": "// Cradler needs lowercase snake_case names, and each column should keep one type.\nconst settings = $('Settings').first().json;\nconst toSnake = (s) => String(s).replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()\n .replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').replace(/^([0-9])/, '_$1');\nconst RESERVED = ['id', 'created_at', 'updated_at'];\n\nlet table = toSnake(settings['Table name']) || 'my_data';\nif (table === 'data_tables') table = 'my_data_tables';\n\nconst raw = $input.all().map((i) => i.json);\nif (!raw.length) throw new Error('The file has no rows.');\n\n// Map original headers to unique snake_case column names.\nconst headers = Object.keys(raw[0]);\nconst used = new Set();\nconst colOf = {};\nheaders.forEach((h, i) => {\n let c = toSnake(h) || `col_${i + 1}`;\n if (RESERVED.includes(c)) c = `src_${c}`;\n while (used.has(c)) c = `${c}_${i + 1}`;\n used.add(c); colOf[h] = c;\n});\n\nconst isNum = (v) => /^-?\\d+(\\.\\d+)?$/.test(v) && !/^0\\d/.test(v);\nconst isBool = (v) => /^(true|false)$/i.test(v);\nconst type = {};\nfor (const h of headers) {\n const vals = raw.map((r) => String(r[h] ?? '').trim()).filter((v) => v !== '');\n type[h] = vals.length && vals.every(isNum) ? 'number' : vals.length && vals.every(isBool) ? 'boolean' : 'text';\n}\n\nconst rows = raw.map((r) => {\n const out = {};\n for (const h of headers) {\n const v = String(r[h] ?? '').trim();\n if (v === '') continue;\n out[colOf[h]] = type[h] === 'number' ? Number(v) : type[h] === 'boolean' ? /^true$/i.test(v) : v;\n }\n return out;\n});\n\nconst size = settings.batch_size || 500;\nconst columns = headers.map((h) => `${colOf[h]}:${type[h]}`).join(', ');\nconst batches = [];\nfor (let i = 0; i < rows.length; i += size) {\n batches.push({ json: { table, columns, total_rows: rows.length, file: settings['Data file']?.filename || '', rows: rows.slice(i, i + size) } });\n}\nreturn batches;" }, "id": "77978ff7-2b30-5569-a365-d383e23b0215", "name": "Prepare rows", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1100, 0 ], "onError": "continueErrorOutput" }, { "parameters": { "method": "POST", "url": "={{ $('Settings').first().json.cradler_api }}/{{ $json.table }}", "authentication": "genericCredentialType", "genericAuthType": "httpBearerAuth", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify($json.rows) }}", "options": { "batching": { "batch": { "batchSize": 1, "batchInterval": 300 } }, "timeout": 120000 } }, "id": "7ad056bc-6368-5eee-a1c9-058f7f001db6", "name": "Insert rows", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 1320, 0 ], "credentials": { "httpBearerAuth": { "id": "REPLACE_WITH_YOUR_CREDENTIAL", "name": "Cradler service key" } }, "onError": "continueErrorOutput" }, { "parameters": { "method": "POST", "url": "={{ $('Settings').first().json.cradler_api }}/data_tables", "authentication": "genericCredentialType", "genericAuthType": "httpBearerAuth", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify([{\n table_name: $('Prepare rows').first().json.table,\n columns: $('Prepare rows').first().json.columns,\n row_count: $('Prepare rows').first().json.total_rows,\n source_file: $('Prepare rows').first().json.file\n}]) }}", "options": { "timeout": 30000 } }, "id": "a72100cb-b7b4-5408-91e3-b5ba7da4d6f1", "name": "Record the table", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 1540, 0 ], "credentials": { "httpBearerAuth": { "id": "REPLACE_WITH_YOUR_CREDENTIAL", "name": "Cradler service key" } }, "executeOnce": true, "onError": "continueErrorOutput" }, { "parameters": { "operation": "completion", "respondWith": "showText", "responseText": "=
\nNow open the chat from the Chat node in n8n and ask a question about this table.
\nImport another file\n{{ $json.error?.message || $json.error || 'Please check the file and try again.' }}
\n\nBack\n