This workflow follows the HTTP Request → OpenAI 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 →
{
"name": "SQL Agent",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "slack-events",
"responseMode": "responseNode",
"options": {}
},
"id": "webhook-node",
"name": "Slack Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 1,
"position": [
200,
300
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "is-url-verification",
"leftValue": "={{ $json.body.type }}",
"rightValue": "url_verification",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "if-verification",
"name": "Is URL Verification?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
420,
300
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { \"challenge\": $json.body.challenge } }}",
"options": {}
},
"id": "respond-challenge",
"name": "Respond Challenge",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1,
"position": [
640,
180
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { \"ok\": true } }}",
"options": {}
},
"id": "respond-ack",
"name": "Acknowledge Slack",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1,
"position": [
640,
420
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "is-message",
"leftValue": "={{ $json.body.event.type }}",
"rightValue": "message",
"operator": {
"type": "string",
"operation": "equals"
}
},
{
"id": "not-bot",
"leftValue": "={{ $json.body.event.bot_id }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "empty",
"singleValue": true
}
},
{
"id": "has-text",
"leftValue": "={{ $json.body.event.text }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "notEmpty",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "filter-messages",
"name": "Filter Valid Messages",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
860,
420
]
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "user-question",
"name": "user_question",
"value": "={{ $json.body.event.text }}",
"type": "string"
},
{
"id": "channel",
"name": "channel",
"value": "={{ $json.body.event.channel }}",
"type": "string"
},
{
"id": "user",
"name": "user",
"value": "={{ $json.body.event.user }}",
"type": "string"
},
{
"id": "attempt",
"name": "attempt",
"value": "=1",
"type": "number"
}
]
},
"options": {}
},
"id": "extract-data",
"name": "Extract Question",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
1080,
340
]
},
{
"parameters": {
"modelId": {
"__rl": true,
"value": "gpt-4o-mini",
"mode": "list"
},
"messages": {
"values": [
{
"content": "You are a SQL expert for a PostgreSQL database with the Northwind schema.\n\nSCHEMA:\n- customers(customer_id, company_name, contact_name, country, city)\n- orders(order_id, customer_id, employee_id, order_date, ship_country, ship_city)\n- order_details(order_id, product_id, unit_price, quantity, discount)\n- products(product_id, product_name, category_id, unit_price, units_in_stock, discontinued)\n- categories(category_id, category_name, description)\n- employees(employee_id, first_name, last_name, title, country, hire_date)\n- suppliers(supplier_id, company_name, country)\n- shippers(shipper_id, company_name)\n\nRULES:\n1. Output ONLY valid JSON in this exact format: {\"sql\": \"...\", \"explanation\": \"...\"}\n2. ONLY SELECT queries. Never DROP, DELETE, UPDATE, INSERT, ALTER, TRUNCATE.\n3. Always add LIMIT 100 unless the user asks for more.\n4. Use proper JOINs with table aliases, never SELECT *.\n5. For revenue calculations: SUM(od.unit_price * od.quantity * (1 - od.discount))\n6. If the question is ambiguous, make a reasonable assumption and note it in explanation.",
"role": "system"
},
{
"content": "={{ $json.user_question }}"
}
]
},
"jsonOutput": true,
"options": {}
},
"id": "sql-generator",
"name": "SQL Generator",
"type": "@n8n/n8n-nodes-langchain.openAi",
"typeVersion": 1.6,
"position": [
1300,
340
]
},
{
"parameters": {
"jsCode": "const aiResponse = $input.first().json;\n\nlet content = aiResponse.message?.content \n || aiResponse.choices?.[0]?.message?.content \n || aiResponse.content\n || aiResponse.text\n || aiResponse;\n\nlet parsed;\nif (typeof content === 'object' && content !== null && content.sql) {\n parsed = content;\n} else {\n let str = typeof content === 'string' ? content : JSON.stringify(content);\n str = str.replace(/```json\\s*/gi, '').replace(/```\\s*/g, '').trim();\n \n try {\n parsed = JSON.parse(str);\n } catch (e) {\n if (str.toLowerCase().startsWith('select') || str.toLowerCase().startsWith('with')) {\n parsed = { sql: str, explanation: 'Raw SQL returned' };\n } else {\n throw new Error('Cannot parse LLM response: ' + str.substring(0, 300));\n }\n }\n}\n\nconst sql = (parsed.sql || '').trim();\n\nif (!sql) {\n throw new Error('No SQL in response: ' + JSON.stringify(parsed).substring(0, 300));\n}\n\nconst sqlLower = sql.toLowerCase();\nconst forbidden = ['drop', 'delete', 'update', 'insert', 'alter', 'truncate', 'create', 'grant', 'revoke'];\nfor (const word of forbidden) {\n if (new RegExp(`\\\\b${word}\\\\b`).test(sqlLower)) {\n throw new Error(`Query rejected: forbidden keyword \"${word}\"`);\n }\n}\n\nif (!sqlLower.startsWith('select') && !sqlLower.startsWith('with')) {\n throw new Error('Query must start with SELECT or WITH. Got: ' + sql.substring(0, 200));\n}\n\nreturn [{\n json: {\n sql: sql,\n explanation: parsed.explanation || '',\n user_question: $('Extract Question').first().json.user_question,\n channel: $('Extract Question').first().json.channel,\n attempt: $('Extract Question').first().json.attempt\n }\n}];"
},
"id": "sql-validator",
"name": "SQL Validator",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1520,
340
]
},
{
"parameters": {
"operation": "executeQuery",
"query": "={{ $json.sql }}",
"options": {}
},
"id": "execute-sql",
"name": "Execute SQL",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.5,
"position": [
1740,
340
],
"onError": "continueRegularOutput",
"alwaysOutputData": true
},
{
"parameters": {
"jsCode": "const items = $input.all();\nconst firstItem = items[0]?.json;\n\nconst validator = $('SQL Validator').first().json;\n\nif (firstItem?.error || (items.length === 1 && firstItem?.error !== undefined)) {\n return [{\n json: {\n success: false,\n error: firstItem.error || 'Unknown SQL error',\n sql: validator.sql,\n user_question: validator.user_question,\n channel: validator.channel,\n attempt: validator.attempt\n }\n }];\n}\n\nconst rows = items.map(i => i.json).filter(r => r && !r.error);\nconst explanation = validator.explanation;\n\nif (rows.length === 0) {\n return [{\n json: {\n success: true,\n text: `Query ran but returned no results.\\n\\n_${explanation}_`,\n rows: [],\n channel: validator.channel,\n sql: validator.sql\n }\n }];\n}\n\nconst headers = Object.keys(rows[0]);\nlet table = '```\\n';\ntable += headers.join(' | ') + '\\n';\ntable += headers.map(() => '---').join(' | ') + '\\n';\nrows.slice(0, 10).forEach(row => {\n table += headers.map(h => {\n const v = row[h];\n if (v === null) return 'NULL';\n if (typeof v === 'number') return Number.isInteger(v) ? String(v) : v.toFixed(2);\n return String(v).slice(0, 30);\n }).join(' | ') + '\\n';\n});\ntable += '```';\n\nconst text = `*Results* (${rows.length} rows)\\n\\n${table}\\n\\n_${explanation}_`;\n\nreturn [{\n json: {\n success: true,\n text,\n rows,\n channel: validator.channel,\n sql: validator.sql\n }\n}];"
},
"id": "format-results",
"name": "Format Results",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1960,
340
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "is-success",
"leftValue": "={{ $json.success }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "check-success",
"name": "Success?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
2180,
340
]
},
{
"parameters": {
"modelId": {
"__rl": true,
"value": "gpt-4o-mini",
"mode": "list"
},
"messages": {
"values": [
{
"content": "You are fixing a failed SQL query for the Northwind PostgreSQL database.\n\nSCHEMA:\n- customers(customer_id, company_name, contact_name, country, city)\n- orders(order_id, customer_id, employee_id, order_date, ship_country, ship_city)\n- order_details(order_id, product_id, unit_price, quantity, discount)\n- products(product_id, product_name, category_id, unit_price, units_in_stock, discontinued)\n- categories(category_id, category_name, description)\n- employees(employee_id, first_name, last_name, title, country, hire_date)\n\nOutput ONLY valid JSON: {\"sql\": \"...\", \"explanation\": \"what you fixed\"}\nOnly SELECT queries. Add LIMIT 100.",
"role": "system"
},
{
"content": "=Original question: {{ $json.user_question }}\nFailed SQL: {{ $json.sql }}\nError: {{ $json.error }}\n\nFix the SQL. Output only JSON."
}
]
},
"jsonOutput": true,
"options": {}
},
"id": "sql-fixer",
"name": "SQL Fixer",
"type": "@n8n/n8n-nodes-langchain.openAi",
"typeVersion": 1.6,
"position": [
2400,
480
]
},
{
"parameters": {
"jsCode": "const currentAttempt = $('Format Results').first().json.attempt || 1;\n\nif (currentAttempt >= 3) {\n return [{\n json: {\n success: true,\n text: `Sorry, I couldn't generate a valid query after 3 attempts.\\n\\nLast error: ${$('Format Results').first().json.error}`,\n rows: [],\n channel: $('Format Results').first().json.channel,\n sql: $('Format Results').first().json.sql,\n giveUp: true\n }\n }];\n}\n\nconst aiResponse = $input.first().json;\nlet content = aiResponse.message?.content \n || aiResponse.choices?.[0]?.message?.content \n || aiResponse.content\n || aiResponse;\n\nlet parsed;\ntry {\n parsed = typeof content === 'string' ? JSON.parse(content) : content;\n} catch (e) {\n throw new Error('Fixer did not return valid JSON');\n}\n\nreturn [{\n json: {\n sql: parsed.sql,\n explanation: parsed.explanation,\n user_question: $('Format Results').first().json.user_question,\n channel: $('Format Results').first().json.channel,\n attempt: currentAttempt + 1\n }\n}];"
},
"id": "process-fix",
"name": "Process Fix",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2620,
480
]
},
{
"parameters": {
"jsCode": "const data = $input.first().json;\nconst rows = data.rows || [];\n\nif (rows.length < 3) {\n return [{ json: { ...data, chartUrl: null } }];\n}\n\nconst headers = Object.keys(rows[0]);\nconst valueKey = headers.find(h => typeof rows[0][h] === 'number' && h !== headers[0]);\n\nif (!valueKey) {\n return [{ json: { ...data, chartUrl: null } }];\n}\n\nconst labelKey = headers[0];\n\nconst config = {\n type: 'bar',\n data: {\n labels: rows.slice(0, 15).map(r => String(r[labelKey]).slice(0, 20)),\n datasets: [{\n label: valueKey,\n data: rows.slice(0, 15).map(r => r[valueKey]),\n backgroundColor: '#4F46E5'\n }]\n },\n options: {\n title: { display: true, text: `${valueKey} by ${labelKey}` },\n legend: { display: false }\n }\n};\n\nconst chartUrl = `https://quickchart.io/chart?c=${encodeURIComponent(JSON.stringify(config))}&width=600&height=400&backgroundColor=white`;\n\nreturn [{ json: { ...data, chartUrl } }];"
},
"id": "build-chart",
"name": "Build Chart",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2400,
240
]
},
{
"parameters": {
"method": "POST",
"url": "https://slack.com/api/chat.postMessage",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ $json.chartUrl ? JSON.stringify({ channel: $json.channel, text: $json.text, blocks: [ { type: 'section', text: { type: 'mrkdwn', text: $json.text } }, { type: 'image', image_url: $json.chartUrl, alt_text: 'chart' } ] }) : JSON.stringify({ channel: $json.channel, text: $json.text, mrkdwn: true }) }}",
"options": {}
},
"id": "send-slack",
"name": "Send to Slack",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
2620,
240
]
}
],
"connections": {
"Slack Webhook": {
"main": [
[
{
"node": "Is URL Verification?",
"type": "main",
"index": 0
}
]
]
},
"Is URL Verification?": {
"main": [
[
{
"node": "Respond Challenge",
"type": "main",
"index": 0
}
],
[
{
"node": "Acknowledge Slack",
"type": "main",
"index": 0
}
]
]
},
"Acknowledge Slack": {
"main": [
[
{
"node": "Filter Valid Messages",
"type": "main",
"index": 0
}
]
]
},
"Filter Valid Messages": {
"main": [
[
{
"node": "Extract Question",
"type": "main",
"index": 0
}
]
]
},
"Extract Question": {
"main": [
[
{
"node": "SQL Generator",
"type": "main",
"index": 0
}
]
]
},
"SQL Generator": {
"main": [
[
{
"node": "SQL Validator",
"type": "main",
"index": 0
}
]
]
},
"SQL Validator": {
"main": [
[
{
"node": "Execute SQL",
"type": "main",
"index": 0
}
]
]
},
"Execute SQL": {
"main": [
[
{
"node": "Format Results",
"type": "main",
"index": 0
}
]
]
},
"Format Results": {
"main": [
[
{
"node": "Success?",
"type": "main",
"index": 0
}
]
]
},
"Success?": {
"main": [
[
{
"node": "Build Chart",
"type": "main",
"index": 0
}
],
[
{
"node": "SQL Fixer",
"type": "main",
"index": 0
}
]
]
},
"SQL Fixer": {
"main": [
[
{
"node": "Process Fix",
"type": "main",
"index": 0
}
]
]
},
"Process Fix": {
"main": [
[
{
"node": "Execute SQL",
"type": "main",
"index": 0
}
]
]
},
"Build Chart": {
"main": [
[
{
"node": "Send to Slack",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
SQL Agent. Uses openAi, postgres, httpRequest. Webhook trigger; 15 nodes.
Source: https://github.com/Bhavitha-Bojja/conversational-data-analyst/blob/main/workflows/sql-agent.json — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
CLINICAINTEGRAL_secretary. Uses postgres, mcpClientTool, googleDriveTool, toolWorkflow. Webhook trigger; 89 nodes.
Remi 1.1. Uses lmChatOpenAi, memoryPostgresChat, openAi, postgres. Webhook trigger; 89 nodes.
my-secretary. Uses postgres, mcpClientTool, googleDriveTool, toolWorkflow. Webhook trigger; 86 nodes.
Aura-bot. Uses postgres, lmChatOpenAi, memoryBufferWindow, httpRequest. Webhook trigger; 82 nodes.
secretaria. Uses postgres, n8n-nodes-evolution-api, openAi, httpRequest. Webhook trigger; 71 nodes.