AutomationFlowsData & Sheets › Shopify AI Support Desk: Answer, Look Up, Escalate (demo)

Shopify AI Support Desk: Answer, Look Up, Escalate (demo)

Shopify AI Support Desk: Answer, Look Up, Escalate (Demo). Uses anthropic, httpRequest, slack, airtable. Webhook trigger; 48 nodes.

Webhook trigger★★★★★ complexityAI-powered48 nodesAnthropicHTTP RequestSlackAirtableGmail TriggerGoogle SheetsShopifyGmail
Data & Sheets Trigger: Webhook Nodes: 48 Complexity: ★★★★★ AI nodes: yes Added:

This workflow follows the Airtable → Gmail 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
{
  "name": "Shopify AI Support Desk: Answer, Look Up, Escalate (Demo)",
  "nodes": [
    {
      "parameters": {
        "content": "## Northbay Supply Co. AI support desk\n\nFour flows in one file. Each has its own trigger, so they run on their own.\n\n1. **Live chat and helpdesk questions.** reads the question, pulls the real order out of Shopify, answers with what the order actually says, or hands it to a person in Slack.\n2. **The support inbox.** email lands, gets answered from the shop's own FAQ, policy sheet and product list, or becomes a helpdesk ticket with the draft already attached.\n3. **Returns and refunds.** the order gets checked against the written policy in one editable rules block. Inside the rules it approves and emails the instructions. Outside them, a person decides. No refund amount moves on its own.\n4. **Weekly read.** Monday morning it looks at everything a person had to take, groups it, and posts the answers worth writing down so next week it covers more.\n\nA question only gets answered on its own when the assistant is confident and the topic is on the safe list. Refunds, damaged parcels, address changes on shipped orders and anyone who sounds upset go straight to a person with everything already gathered.\n\n**What n8n owns here:** the triggers, the Shopify and FAQ lookups, the model calls, the routing, the sending, and the log. Your helpdesk and chat widget stay where they are and post into the webhooks. Swapping Claude for OpenAI is a one-node change.\n\nEvery name, address, order and value on this canvas is demo data. Northbay Supply Co. is a made-up company. This is a demonstration build, not a copy of anyone's production file.",
        "height": 700,
        "width": 760,
        "color": 1
      },
      "id": "read-me-first-sticky",
      "name": "Read me first",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -1000,
        -620
      ]
    },
    {
      "id": "s1-sticky-front-door",
      "name": "Live chat and helpdesk inquiries",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -140,
        -400
      ],
      "parameters": {
        "width": 2900,
        "height": 840,
        "color": 3,
        "content": "## Live chat and helpdesk questions get answered\n\n- A question comes in from the chat widget or Gorgias, Claude works out what the customer actually needs, the real order gets pulled from Shopify, and the reply is written off those order facts and nothing else.\n- Easy ones go straight back to the customer. Refunds, damaged packages, address changes, angry customers and anything the assistant is under 80% sure about land in #support-escalations with the order link, and the customer is told a person has it. Every question, either way, gets logged once in Airtable."
      }
    },
    {
      "id": "s1-inquiry-webhook",
      "name": "A customer question arrives from chat or the helpdesk",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        0
      ],
      "parameters": {
        "httpMethod": "POST",
        "path": "northbay/support-inquiry",
        "responseMode": "responseNode",
        "options": {}
      }
    },
    {
      "id": "s1-shape-the-question",
      "name": "Put every question into the same shape",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        220,
        0
      ],
      "parameters": {
        "jsCode": "// ---------- EDITABLE RULES ----------\n// Northbay gets questions from the Gorgias helpdesk, the chat widget on the site,\n// and a plain form post. Each one hands us a different shape, so every field below\n// has a list of places to look. Add a new source by adding another place to the list.\nconst DEFAULT_CHANNEL = 'chat';\nconst SUPPORT_INBOX = 'support@northbay-supply.example.com';\n// ------------------------------------\n\nconst pick = (...candidates) => {\n  for (const c of candidates) {\n    if (c === null || c === undefined) continue;\n    const s = String(c).trim();\n    if (s) return s;\n  }\n  return '';\n};\n\nconst out = [];\n\nfor (const item of $input.all()) {\n  const root = item.json || {};\n  const body = root.body || root;\n  const ticket = body.ticket || body.gorgias_ticket || {};\n  const messages = Array.isArray(ticket.messages) ? ticket.messages : [];\n  const firstMessage = messages[0] || {};\n  const sender = firstMessage.sender || {};\n  const customer = body.customer || ticket.customer || sender || {};\n\n  const messageText = pick(\n    firstMessage.body_text,\n    firstMessage.stripped_text,\n    firstMessage.body_html,\n    body.message,\n    body.text,\n    body.question,\n    body.inquiry,\n    body.comment,\n    ticket.subject\n  );\n\n  const customerEmail = pick(\n    customer.email,\n    body.email,\n    body.customer_email,\n    body.from_email,\n    firstMessage.from_email,\n    sender.email\n  ).toLowerCase();\n\n  const customerName = pick(\n    customer.name,\n    body.name,\n    body.customer_name,\n    sender.name,\n    [customer.firstname, customer.lastname].filter(Boolean).join(' '),\n    [body.first_name, body.last_name].filter(Boolean).join(' '),\n    customerEmail ? customerEmail.split('@')[0] : ''\n  );\n\n  // Order number can come in as a field, or buried in the sentence as #10482.\n  const fromText = messageText.match(/#\\s*(\\d{3,})/) || messageText.match(/order\\s*(?:number|no\\.?|#)?\\s*(\\d{3,})/i);\n  const rawOrder = pick(\n    body.order_number,\n    body.orderNumber,\n    body.order_name,\n    body.order,\n    ticket.order_number,\n    (ticket.meta || {}).order_number,\n    fromText ? fromText[1] : ''\n  );\n  const orderNumber = rawOrder.replace(/[^0-9]/g, '');\n\n  const conversationId = pick(\n    ticket.id,\n    body.ticket_id,\n    body.conversation_id,\n    body.chat_id,\n    body.session_id,\n    root.headers ? root.headers['x-request-id'] : '',\n    'chat-' + Date.now()\n  );\n\n  const channel = pick(\n    body.channel,\n    ticket.channel,\n    firstMessage.channel,\n    messages.length ? 'helpdesk' : '',\n    DEFAULT_CHANNEL\n  );\n\n  out.push({\n    json: {\n      message_text: messageText,\n      customer_email: customerEmail,\n      customer_name: customerName || 'there',\n      order_number: orderNumber,\n      conversation_id: conversationId,\n      channel: channel,\n      support_inbox: SUPPORT_INBOX,\n      received_at: new Date().toISOString()\n    }\n  });\n}\n\nreturn out;\n"
      }
    },
    {
      "id": "s1-ask-claude-intent",
      "name": "Ask Claude what the customer needs",
      "type": "@n8n/n8n-nodes-langchain.anthropic",
      "typeVersion": 1,
      "position": [
        440,
        0
      ],
      "parameters": {
        "resource": "text",
        "operation": "message",
        "modelId": {
          "__rl": true,
          "mode": "id",
          "value": "claude-sonnet-5"
        },
        "messages": {
          "values": [
            {
              "role": "user",
              "content": "=A customer just wrote in to Northbay Supply Co.\n\nChannel: {{ $json.channel }}\nName they gave: {{ $json.customer_name }}\nEmail they gave: {{ $json.customer_email }}\nOrder number they gave: {{ $json.order_number }}\n\nWhat they wrote:\n{{ $json.message_text }}\n\nAnswer with the JSON object only."
            }
          ]
        },
        "simplify": false,
        "options": {
          "system": "You read customer questions for Northbay Supply Co., an outdoor gear store. Your only job is to say what the customer needs so the rest of the flow can go get it.\n\nAnswer with a JSON object and nothing else. No code fence, no sentence before it, no sentence after it. Use exactly these keys:\nintent, order_number, email, sentiment, summary\n\nintent must be one of: order_status, shipping_eta, product_question, returns_policy, refund_request, damaged_or_lost, address_change, other\nsentiment must be one of: calm, frustrated, angry\n\nRules:\n- Leave a field as an empty string rather than guessing. An empty order_number is a fine answer. A made up one is not.\n- Only put digits in order_number. Drop the # if they wrote one.\n- Only use an email the customer actually gave you.\n- If the message covers two things, pick the one they lead with.\n- If nothing fits, use other. Do not stretch a category to make it fit.\n- summary is one short line saying what they want, in the customer's own terms.\n\nVoice rules for summary: write like a person texting. Plain words. No corporate phrases. No emojis. No em dashes. Never say reach out. No three item lists for rhythm. Short sentences.",
          "maxTokens": 400
        }
      },
      "credentials": {
        "anthropicApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "s1-read-claude-intent",
      "name": "Read what Claude found",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        660,
        0
      ],
      "parameters": {
        "jsCode": "// Claude was told to answer with JSON only. Sometimes a model wraps it in a fence\n// or adds a sentence. Dig the JSON out. If it still will not read, hand the whole\n// thing to a person instead of guessing at what the customer wanted.\nconst asked = $('Put every question into the same shape').first().json;\n\nconst raw = $json.content?.[0]?.text ?? $json.text ?? $json.output ?? '';\nconst trimmed = String(raw).replace(/```json/gi, '').replace(/```/g, '').trim();\nconst start = trimmed.indexOf('{');\nconst end = trimmed.lastIndexOf('}');\n\nlet parsed = null;\nlet readable = true;\n\ntry {\n  parsed = JSON.parse(start >= 0 && end > start ? trimmed.slice(start, end + 1) : trimmed);\n} catch (e) {\n  readable = false;\n  parsed = {};\n}\n\nconst ALLOWED_INTENTS = [\n  'order_status', 'shipping_eta', 'product_question', 'returns_policy',\n  'refund_request', 'damaged_or_lost', 'address_change', 'other'\n];\nconst ALLOWED_SENTIMENT = ['calm', 'frustrated', 'angry'];\n\nconst intent = ALLOWED_INTENTS.includes(parsed.intent) ? parsed.intent : 'other';\nconst sentiment = ALLOWED_SENTIMENT.includes(parsed.sentiment) ? parsed.sentiment : 'calm';\nconst orderNumber = String(parsed.order_number || asked.order_number || '').replace(/[^0-9]/g, '');\n\nreturn [{\n  json: {\n    intent: intent,\n    sentiment: sentiment,\n    order_number: orderNumber,\n    email: String(parsed.email || asked.customer_email || '').toLowerCase(),\n    summary: String(parsed.summary || asked.message_text || '').trim(),\n    model_was_readable: readable,\n    needs_human: readable ? false : true,\n    conversation_id: asked.conversation_id,\n    channel: asked.channel,\n    customer_name: asked.customer_name,\n    customer_email: asked.customer_email,\n    message_text: asked.message_text\n  }\n}];\n"
      }
    },
    {
      "id": "s1-find-shopify-order",
      "name": "Find the order in Shopify",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        880,
        0
      ],
      "parameters": {
        "method": "GET",
        "url": "https://northbay-supply-demo.myshopify.com/admin/api/2024-07/orders.json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "name",
              "value": "={{ $json.order_number }}"
            },
            {
              "name": "status",
              "value": "any"
            }
          ]
        },
        "options": {}
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 3000,
      "onError": "continueErrorOutput",
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "s1-order-facts",
      "name": "Pull out the order facts worth quoting",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1100,
        0
      ],
      "parameters": {
        "jsCode": "// ---------- EDITABLE RULES ----------\nconst STORE_HANDLE = 'northbay-supply';\nconst CARRIER_FALLBACK = 'the carrier';\n// ------------------------------------\n\nconst orders = Array.isArray($json.orders) ? $json.orders : [];\nconst order = orders[0];\n\nif (!order) {\n  return [{\n    json: {\n      order_found: false,\n      order_name: '',\n      order_admin_url: '',\n      facts_block: 'No order matched what the customer gave us. Nothing about their order is known.'\n    }\n  }];\n}\n\nconst money = (v) => '$' + Number(v || 0).toFixed(2);\n\nconst lineItems = (order.line_items || []).map(li => ({\n  title: li.title,\n  quantity: li.quantity\n}));\n\nconst tracking = (order.fulfillments || []).map(f => ({\n  carrier: f.tracking_company || CARRIER_FALLBACK,\n  number: (f.tracking_numbers || [])[0] || f.tracking_number || '',\n  url: (f.tracking_urls || [])[0] || f.tracking_url || '',\n  shipment_status: f.shipment_status || f.status || ''\n})).filter(t => t.number || t.url);\n\nconst ship = order.shipping_address || {};\nconst shippingCity = [ship.city, ship.province_code || ship.province].filter(Boolean).join(', ');\n\nconst itemsText = lineItems.length\n  ? lineItems.map(li => li.quantity + ' x ' + li.title).join('\\n')\n  : 'No items listed on the order.';\n\nconst trackingText = tracking.length\n  ? tracking.map(t => t.carrier + ' ' + t.number + (t.url ? ' ' + t.url : '') + (t.shipment_status ? ' (' + t.shipment_status + ')' : '')).join('\\n')\n  : 'Nothing has shipped yet, so there is no tracking number.';\n\nconst facts = [\n  'Order: ' + order.name,\n  'Placed: ' + String(order.created_at || '').slice(0, 10),\n  'Payment status: ' + (order.financial_status || 'unknown'),\n  'Fulfillment status: ' + (order.fulfillment_status || 'unfulfilled'),\n  'Total: ' + money(order.total_price),\n  'Shipping to: ' + (shippingCity || 'no address on the order'),\n  'Items:',\n  itemsText,\n  'Tracking:',\n  trackingText\n].join('\\n');\n\nreturn [{\n  json: {\n    order_found: true,\n    order_id: order.id,\n    order_name: order.name,\n    order_date: String(order.created_at || '').slice(0, 10),\n    financial_status: order.financial_status || 'unknown',\n    fulfillment_status: order.fulfillment_status || 'unfulfilled',\n    total_price: money(order.total_price),\n    line_items: lineItems,\n    tracking: tracking,\n    shipping_city: shippingCity,\n    order_admin_url: 'https://admin.shopify.com/store/' + STORE_HANDLE + '/orders/' + order.id,\n    facts_block: facts\n  }\n}];\n"
      }
    },
    {
      "id": "s1-write-the-reply",
      "name": "Write the reply using only the order facts",
      "type": "@n8n/n8n-nodes-langchain.anthropic",
      "typeVersion": 1,
      "position": [
        1320,
        0
      ],
      "parameters": {
        "resource": "text",
        "operation": "message",
        "modelId": {
          "__rl": true,
          "mode": "id",
          "value": "claude-sonnet-5"
        },
        "messages": {
          "values": [
            {
              "role": "user",
              "content": "=Here is the question and everything known about the order. Nothing outside this block is known.\n\nWhat they asked: {{ $('Read what Claude found').item.json.message_text }}\nWhat they need: {{ $('Read what Claude found').item.json.intent }}\nHow they sound: {{ $('Read what Claude found').item.json.sentiment }}\nTheir name: {{ $('Read what Claude found').item.json.customer_name }}\n\nOrder facts:\n{{ $json.facts_block }}\n\nWrite the reply. JSON object only."
            }
          ]
        },
        "simplify": false,
        "options": {
          "system": "You write customer service replies for Northbay Supply Co., an outdoor gear store. The store is northbay-supply.example.com and the support inbox is support@northbay-supply.example.com. Returns are accepted for 30 days.\n\nGround every word in the order facts you are handed. Those facts are the only thing you know.\n- Do not invent a date, a tracking number, a carrier, an item or a total.\n- If a fact is not in what you were given, say plainly that you do not have it and that someone will check. Never fill the gap with a guess.\n- If no order was found, say so and ask for the order number or the email the order was placed under.\n- Never promise a delivery date the carrier has not given.\n\nVoice rules. Follow them exactly: write like a person texting. Plain words. No corporate phrases. No emojis. No em dashes. Never say reach out. No three item lists for rhythm. Short sentences.\n\nAnswer with a JSON object and nothing else. No code fence, no sentence around it. Use exactly these keys:\nreply, confidence, needs_human, reason\n\nreply is the message the customer reads. Two or three short lines is plenty.\nconfidence is a number from 0 to 1 for how sure you are the reply is right and complete. Be honest and go low when the facts are thin.\nneeds_human is true when a person should handle it instead of you. Set it true for refunds, damaged or lost packages, address changes after shipping, anything where the facts do not cover the question, and anyone who sounds angry.\nreason is one short line saying why you set needs_human the way you did.",
          "maxTokens": 700
        }
      },
      "credentials": {
        "anthropicApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "s1-safe-to-send-check",
      "name": "Check the answer is safe to send on its own",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1540,
        0
      ],
      "parameters": {
        "jsCode": "// ---------- EDITABLE RULES ----------\nconst RULES = {\n  // how sure the assistant has to be before its answer goes out on its own\n  CONFIDENCE_FLOOR: 0.8,\n  // questions the assistant is allowed to answer by itself\n  AUTO_OK_INTENTS: ['order_status', 'shipping_eta', 'product_question', 'returns_policy', 'faq'],\n  // money and address changes always go to a person, no matter how sure the model is\n  NEVER_AUTO: ['refund_request', 'damaged_or_lost', 'address_change'],\n  // an angry customer always gets a person\n  ANGRY_GOES_TO_A_PERSON: true\n};\n// ------------------------------------\n\nconst found = $('Read what Claude found').first().json;\nconst facts = $('Pull out the order facts worth quoting').first().json;\n\nconst raw = $json.content?.[0]?.text ?? $json.text ?? $json.output ?? '';\nconst trimmed = String(raw).replace(/```json/gi, '').replace(/```/g, '').trim();\nconst start = trimmed.indexOf('{');\nconst end = trimmed.lastIndexOf('}');\n\nlet draft = {};\nlet readable = true;\ntry {\n  draft = JSON.parse(start >= 0 && end > start ? trimmed.slice(start, end + 1) : trimmed);\n} catch (e) {\n  readable = false;\n}\n\nconst replyText = String(draft.reply || '').trim();\nconst confidence = Number(draft.confidence);\nconst score = Number.isFinite(confidence) ? confidence : 0;\n\nlet send = true;\nlet reason = 'Confident answer on a question the assistant is allowed to handle.';\n\nif (!readable || !replyText) {\n  send = false;\n  reason = 'The draft reply did not come back in a form we could read, so nothing was sent.';\n} else if (draft.needs_human === true) {\n  send = false;\n  reason = String(draft.reason || 'The assistant asked for a person on this one.');\n} else if (RULES.NEVER_AUTO.includes(found.intent)) {\n  send = false;\n  reason = 'This is a ' + found.intent.replace(/_/g, ' ') + ', and those always go to a person.';\n} else if (!RULES.AUTO_OK_INTENTS.includes(found.intent)) {\n  send = false;\n  reason = 'The question did not land in a category the assistant answers on its own.';\n} else if (RULES.ANGRY_GOES_TO_A_PERSON && found.sentiment === 'angry') {\n  send = false;\n  reason = 'The customer is angry, so a person takes this one.';\n} else if (score < RULES.CONFIDENCE_FLOOR) {\n  send = false;\n  reason = 'The assistant was only ' + Math.round(score * 100) + '% sure, and the floor is ' + Math.round(RULES.CONFIDENCE_FLOOR * 100) + '%.';\n} else if (found.needs_human === true) {\n  send = false;\n  reason = 'We could not read what the customer was asking for, so a person takes it.';\n}\n\nreturn [{\n  json: {\n    send_automatically: send,\n    reason: reason,\n    reply_text: replyText,\n    confidence: score,\n    intent: found.intent,\n    sentiment: found.sentiment,\n    order_found: facts.order_found === true,\n    order_name: facts.order_name || '',\n    order_admin_url: facts.order_admin_url || '',\n    conversation_id: found.conversation_id,\n    channel: found.channel,\n    customer_name: found.customer_name,\n    customer_email: found.customer_email,\n    message_text: found.message_text\n  }\n}];\n"
      }
    },
    {
      "id": "s1-can-it-go-alone",
      "name": "Can this one go out on its own?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1760,
        0
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "s1cond-send-auto",
              "leftValue": "={{ $json.send_automatically }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "s1-send-the-answer",
      "name": "Send the answer back to the customer",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        1980,
        -180
      ],
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ status: 'answered', reply: $('Check the answer is safe to send on its own').first().json.reply_text, order_name: $('Check the answer is safe to send on its own').first().json.order_name, handled_by: 'assistant' }) }}",
        "options": {
          "responseCode": 200
        }
      }
    },
    {
      "id": "s1-post-to-support-channel",
      "name": "Post it in the support channel for a person",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1980,
        180
      ],
      "parameters": {
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "#support-escalations"
        },
        "text": "=A question needs a person.\n\nCustomer: {{ $('Put every question into the same shape').first().json.customer_name }} ({{ $('Put every question into the same shape').first().json.customer_email || 'no email given' }})\nChannel: {{ $('Put every question into the same shape').first().json.channel }}\nTicket: {{ $('Put every question into the same shape').first().json.conversation_id }}\n\nThey asked:\n{{ $('Put every question into the same shape').first().json.message_text }}\n\nWhat the assistant found: {{ $('Read what Claude found').isExecuted ? $('Read what Claude found').first().json.intent + ', sounds ' + $('Read what Claude found').first().json.sentiment + '. ' + $('Read what Claude found').first().json.summary : 'It did not get that far.' }}\nOrder: {{ $('Pull out the order facts worth quoting').isExecuted && $('Pull out the order facts worth quoting').first().json.order_found ? $('Pull out the order facts worth quoting').first().json.order_name + ', ' + $('Pull out the order facts worth quoting').first().json.fulfillment_status : 'no order matched' }}\nWhy it stopped: {{ $('Check the answer is safe to send on its own').isExecuted ? $('Check the answer is safe to send on its own').first().json.reason : 'Shopify did not answer when we went to look up the order.' }}\nDraft the assistant had: {{ $('Check the answer is safe to send on its own').isExecuted ? ($('Check the answer is safe to send on its own').first().json.reply_text || 'none') : 'none' }}\n\nOrder in Shopify: {{ $('Pull out the order facts worth quoting').isExecuted && $('Pull out the order facts worth quoting').first().json.order_admin_url ? $('Pull out the order facts worth quoting').first().json.order_admin_url : 'https://admin.shopify.com/store/northbay-supply/orders' }}",
        "otherOptions": {}
      },
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "s1-tell-them-a-person-has-it",
      "name": "Tell the customer a person is picking it up",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        2200,
        180
      ],
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ status: 'escalated', reply: 'Thanks for writing in. Someone on our team is picking this one up now and will get back to you at ' + ($('Put every question into the same shape').first().json.customer_email || 'the email on your order') + '.', order_name: $('Pull out the order facts worth quoting').isExecuted ? $('Pull out the order facts worth quoting').first().json.order_name : '', handled_by: 'human' }) }}",
        "options": {
          "responseCode": 200
        }
      }
    },
    {
      "id": "s1-log-the-conversation",
      "name": "Log the conversation",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 2.1,
      "position": [
        2420,
        0
      ],
      "parameters": {
        "resource": "record",
        "operation": "create",
        "base": {
          "__rl": true,
          "mode": "id",
          "value": "appNorthbaySupport01"
        },
        "table": {
          "__rl": true,
          "mode": "id",
          "value": "tblConversations"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "value": {
            "Ticket ID": "={{ $('Put every question into the same shape').first().json.conversation_id }}",
            "Channel": "={{ $('Put every question into the same shape').first().json.channel }}",
            "Customer Email": "={{ $('Put every question into the same shape').first().json.customer_email }}",
            "Intent": "={{ $('Read what Claude found').isExecuted ? $('Read what Claude found').first().json.intent : 'other' }}",
            "Order Name": "={{ $('Pull out the order facts worth quoting').isExecuted ? $('Pull out the order facts worth quoting').first().json.order_name : '' }}",
            "Confidence": "={{ $('Check the answer is safe to send on its own').isExecuted ? $('Check the answer is safe to send on its own').first().json.confidence : 0 }}",
            "Handled By": "={{ $('Check the answer is safe to send on its own').isExecuted && $('Check the answer is safe to send on its own').first().json.send_automatically ? 'assistant' : 'human' }}",
            "Reason": "={{ $('Check the answer is safe to send on its own').isExecuted ? $('Check the answer is safe to send on its own').first().json.reason : 'Shopify did not answer when we went to look up the order, so a person took it.' }}",
            "Reply Sent": "={{ $('Check the answer is safe to send on its own').isExecuted && $('Check the answer is safe to send on its own').first().json.send_automatically ? $('Check the answer is safe to send on its own').first().json.reply_text : '' }}"
          },
          "schema": [
            {
              "id": "Ticket ID",
              "displayName": "Ticket ID",
              "type": "string",
              "required": false,
              "display": true,
              "removed": false,
              "readOnly": false,
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Channel",
              "displayName": "Channel",
              "type": "string",
              "required": false,
              "display": true,
              "removed": false,
              "readOnly": false,
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Customer Email",
              "displayName": "Customer Email",
              "type": "string",
              "required": false,
              "display": true,
              "removed": false,
              "readOnly": false,
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Intent",
              "displayName": "Intent",
              "type": "string",
              "required": false,
              "display": true,
              "removed": false,
              "readOnly": false,
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Order Name",
              "displayName": "Order Name",
              "type": "string",
              "required": false,
              "display": true,
              "removed": false,
              "readOnly": false,
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Confidence",
              "displayName": "Confidence",
              "type": "string",
              "required": false,
              "display": true,
              "removed": false,
              "readOnly": false,
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Handled By",
              "displayName": "Handled By",
              "type": "string",
              "required": false,
              "display": true,
              "removed": false,
              "readOnly": false,
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Reason",
              "displayName": "Reason",
              "type": "string",
              "required": false,
              "display": true,
              "removed": false,
              "readOnly": false,
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Reply Sent",
              "displayName": "Reply Sent",
              "type": "string",
              "required": false,
              "display": true,
              "removed": false,
              "readOnly": false,
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": true
        },
        "options": {}
      },
      "credentials": {
        "airtableTokenApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "content": "## The support inbox\n\n- Mail to support@northbay-supply.example.com gets read, answered off the shop's own FAQ sheet and the live product list, and sent straight back to the customer.\n- Anything the facts do not cover, or anything about money or a damaged parcel, becomes a Gorgias ticket with the draft already attached and a Slack ping to the team. Either way the conversation lands in Airtable.",
        "height": 840,
        "width": 2680,
        "color": 5
      },
      "id": "s2-sticky-support-inbox",
      "name": "What the support inbox lane does",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -140,
        500
      ]
    },
    {
      "parameters": {
        "pollTimes": {
          "item": [
            {
              "mode": "everyMinute"
            }
          ]
        },
        "simple": false,
        "filters": {
          "labelIds": [
            "Label_4471029386"
          ],
          "readStatus": "unread"
        },
        "options": {}
      },
      "id": "s2-gmail-trigger",
      "name": "A new email lands in the support inbox",
      "type": "n8n-nodes-base.gmailTrigger",
      "typeVersion": 1.4,
      "position": [
        0,
        900
      ],
      "credentials": {
        "gmailOAuth2": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Pull the sender, the subject and the real question out of the email.\n// Written to tolerate a message that is missing half its fields.\n\nfunction headerValue(j, wanted) {\n  const h = j.headers || (j.payload && j.payload.headers) || {};\n  if (Array.isArray(h)) {\n    const hit = h.find(x => String(x.name || '').toLowerCase() === wanted);\n    return hit ? String(hit.value || '') : '';\n  }\n  return String(h[wanted] || h[wanted.toLowerCase()] || '');\n}\n\n// Drop the quoted trailer so the model reads what they actually wrote this time.\nfunction trimQuotedTrailer(text) {\n  const lines = String(text || '').replace(/\\r\\n/g, '\\n').split('\\n');\n  const kept = [];\n  for (const line of lines) {\n    const l = line.trim();\n    if (/^on .+wrote:$/i.test(l)) break;\n    if (/^-{2,}\\s*original message\\s*-{2,}$/i.test(l)) break;\n    if (/^sent from my /i.test(l)) break;\n    if (kept.length && /^from:\\s/i.test(l)) break;\n    if (l.startsWith('>')) continue;\n    kept.push(line);\n  }\n  return kept.join('\\n').replace(/\\n{3,}/g, '\\n\\n').trim();\n}\n\nconst out = [];\n\nfor (const item of $input.all()) {\n  const j = item.json || {};\n\n  const fromRaw = (j.from && j.from.value && j.from.value[0] && j.from.value[0].address)\n    || headerValue(j, 'from')\n    || j.From\n    || '';\n  const match = String(fromRaw).match(/[\\w.+-]+@[\\w.-]+\\.\\w+/);\n  const senderEmail = match ? match[0].toLowerCase() : '';\n\n  const fromName = (j.from && j.from.value && j.from.value[0] && j.from.value[0].name) || '';\n  const bracketName = String(fromRaw).split('<')[0].replace(/\"/g, '').trim();\n  const senderName = (fromName || bracketName || senderEmail.split('@')[0] || 'there').trim();\n\n  const subject = String(j.subject || headerValue(j, 'subject') || '(no subject)').trim();\n\n  const htmlAsText = j.textAsHtml ? String(j.textAsHtml).replace(/<[^>]+>/g, ' ') : '';\n  const body = trimQuotedTrailer(j.text || j.textPlain || htmlAsText || j.snippet || '');\n\n  // If they named an order in the email, keep it. Shape is #1042.\n  const orderMatch = (subject + ' ' + body).match(/#\\s?(\\d{4,7})/);\n\n  out.push({\n    json: {\n      messageId: String(j.id || j.messageId || ''),\n      threadId: String(j.threadId || ''),\n      senderEmail,\n      senderName,\n      firstName: senderName.split(' ')[0],\n      subject,\n      question: body || String(j.snippet || '').trim(),\n      orderName: orderMatch ? '#' + orderMatch[1] : '',\n      receivedAt: j.date || new Date().toISOString()\n    }\n  });\n}\n\nreturn out;\n"
      },
      "id": "s2-read-the-email",
      "name": "Pick out the question and who sent it",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        220,
        900
      ]
    },
    {
      "parameters": {
        "operation": "read",
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "1NbSupplyFaqPolicies2026Demo"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "FAQ & Policies"
        },
        "options": {}
      },
      "id": "s2-read-faq-sheet",
      "name": "Read the shop's FAQ and policies",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        440,
        900
      ],
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "authentication": "accessToken",
        "resource": "product",
        "operation": "getAll",
        "returnAll": false,
        "limit": 25,
        "options": {}
      },
      "id": "s2-shopify-products",
      "name": "Look up the matching products",
      "type": "n8n-nodes-base.shopify",
      "typeVersion": 1,
      "position": [
        660,
        900
      ],
      "executeOnce": true,
      "credentials": {
        "shopifyAccessTokenApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Put the FAQ rows and the live product list into one small block the model can read.\n// The point is to hand over facts, not the whole database.\n\nconst MAX_FAQ_ROWS = 40;\nconst MAX_PRODUCTS = 25;\n\nconst email = $('Pick out the question and who sent it').first().json;\n\nconst faqRows = $(\"Read the shop's FAQ and policies\").all()\n  .map(i => i.json || {})\n  .filter(r => r.Question || r.Answer)\n  .slice(0, MAX_FAQ_ROWS)\n  .map(r => ({\n    topic: String(r.Topic || 'general').trim(),\n    question: String(r.Question || '').trim(),\n    answer: String(r.Answer || '').trim(),\n    lastUpdated: String(r['Last Updated'] || '').trim()\n  }));\n\nconst products = $('Look up the matching products').all()\n  .map(i => i.json || {})\n  .slice(0, MAX_PRODUCTS)\n  .map(p => ({\n    title: String(p.title || '').trim(),\n    handle: String(p.handle || '').trim(),\n    type: String(p.product_type || '').trim(),\n    vendor: String(p.vendor || '').trim(),\n    tags: String(p.tags || '').trim()\n  }))\n  .filter(p => p.title);\n\nconst faqBlock = faqRows.length\n  ? faqRows.map(r => '[' + r.topic + '] ' + r.question + '\\n' + r.answer + '\\n(last updated ' + (r.lastUpdated || 'not dated') + ')').join('\\n\\n')\n  : 'No FAQ rows came back from the sheet.';\n\nconst productBlock = products.length\n  ? products.map(p => p.title + ' | ' + (p.type || 'no type') + ' | ' + (p.vendor || 'no vendor') + ' | northbay-supply.example.com/products/' + p.handle + ' | tags: ' + (p.tags || 'none')).join('\\n')\n  : 'No products came back from the store.';\n\nreturn [{\n  json: Object.assign({}, email, {\n    faqCount: faqRows.length,\n    productCount: products.length,\n    faqBlock,\n    productBlock\n  })\n}];\n"
      },
      "id": "s2-build-answer-sheet",
      "name": "Build one answer sheet for the assistant",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        900
      ]
    },
    {
      "parameters": {
        "resource": "text",
        "operation": "message",
        "modelId": {
          "__rl": true,
          "mode": "id",
          "value": "claude-sonnet-5"
        },
        "messages": {
          "values": [
            {
              "role": "user",
              "content": "=A customer emailed the shop. Answer them from the facts below and nothing else.\n\nFrom: {{ $json.senderName }} <{{ $json.senderEmail }}>\nSubject: {{ $json.subject }}\nOrder they named: {{ $json.orderName || 'none' }}\n\nWhat they wrote:\n{{ $json.question }}\n\nFAQ and policy rows from the shop's own sheet, {{ $json.faqCount }} of them:\n{{ $json.faqBlock }}\n\nProducts in the shop right now, {{ $json.productCount }} of them:\n{{ $json.productBlock }}"
            }
          ]
        },
        "simplify": false,
        "options": {
          "system": "You answer customer emails for Northbay Supply Co., an outdoor gear shop at northbay-supply.example.com. The inbox is support@northbay-supply.example.com.\n\nWhat you are allowed to use:\n- The FAQ and policy rows handed to you in the message.\n- The product list handed to you in the message.\nThat is the whole world. Anything else you know about gear, carriers or returns does not count here.\n\nGrounding rules:\n- Every fact in your reply comes from those FAQ rows or that product list.\n- If the answer is not in there, say so plainly in the reply and set needs_human to true. Do not invent a policy, a price, a date, a stock count or a tracking status.\n- Do not guess at an order. If they ask about one specific order and no order facts were handed to you, that goes to a person.\n- Copy numbers, dates and windows exactly as the FAQ rows write them.\n- If two rows disagree, use the one with the later last updated date and set needs_human to true.\n\nVoice rules. Follow them exactly:\n- Write like a person, not a brand.\n- Plain words.\n- No corporate phrases.\n- No emojis.\n- No em dashes.\n- Never write \"reach out\".\n- No three-item lists put there for rhythm.\n- Short sentences.\n- Sign off as the Northbay Supply support team.\n\nReply with a JSON object and nothing else. No code fence, no sentence before it, no sentence after it. Use exactly these keys:\n\nreply - the full email body you would send, greeting and sign off included\nconfidence - a number from 0 to 1 for how well the supplied facts actually answer them\ntopic - one of: faq, shipping_policy, product_question, returns_policy, sizing, order_status, refund_amount, damaged_or_lost, complaint, other\nneeds_human - true or false\nreason - one short line saying why it can go on its own, or why a person is needed",
          "maxTokens": 900
        }
      },
      "id": "s2-write-the-reply",
      "name": "Write the reply from the shop's own answers",
      "type": "@n8n/n8n-nodes-langchain.anthropic",
      "typeVersion": 1,
      "position": [
        1100,
        900
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 3000,
      "credentials": {
        "anthropicApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// ---------- EDITABLE RULES ----------\nconst CONFIDENCE_FLOOR = 0.8;\n\n// Topics the shop is happy to have answered without a person reading it first.\nconst AUTO_OK_TOPICS = [\n  'faq',\n  'shipping_policy',\n  'product_question',\n  'returns_policy',\n  'sizing',\n  'order_status'\n];\n\n// Topics that always go to a person, whatever the model thinks.\nconst ALWAYS_A_PERSON = ['refund_amount', 'damaged_or_lost', 'complaint'];\n\n// Words that mean money or trouble. Any hit and a person reads it.\nconst MONEY_OR_TROUBLE = [\n  'refund', 'refunded', 'chargeback', 'charged twice', 'double charged',\n  'damaged', 'broken', 'cracked', 'smashed', 'defective',\n  'lost', 'never arrived', 'never showed', 'missing', 'stolen',\n  'complaint', 'unacceptable', 'lawyer', 'bbb', 'dispute'\n];\n// ------------------------------------\n\nconst email = $('Pick out the question and who sent it').first().json;\nconst raw = $input.first().json;\n\nconst text = (raw.content && raw.content[0] && raw.content[0].text) || raw.text || raw.output || '';\n\nlet model = {};\ntry {\n  model = JSON.parse(String(text).replace(/^```(json)?/i, '').replace(/```$/, '').trim());\n} catch (e) {\n  model = {};\n}\n\nconst reply = String(model.reply || '').trim();\nconst confidence = Number.isFinite(Number(model.confidence)) ? Number(model.confidence) : 0;\nconst topic = String(model.topic || 'other').trim().toLowerCase();\nconst modelWantsHuman = model.needs_human === true;\nconst modelReason = String(model.reason || '').trim();\n\nconst haystack = ((email.subject || '') + ' ' + (email.question || '')).toLowerCase();\nconst wordHit = MONEY_OR_TROUBLE.find(w => haystack.includes(w)) || '';\n\nconst blockers = [];\nif (modelWantsHuman) blockers.push(modelReason || 'the assistant asked for a person');\nif (!reply) blockers.push('the assistant did not write a reply');\nif (confidence < CONFIDENCE_FLOOR) blockers.push('confidence ' + confidence + ' is under the ' + CONFIDENCE_FLOOR + ' floor');\nif (ALWAYS_A_PERSON.includes(topic)) blockers.push(topic + ' always goes to a person');\nif (!AUTO_OK_TOPICS.includes(topic)) blockers.push(topic + ' is not on the list that can go out on its own');\nif (wordHit) blockers.push('the email says \"' + wordHit + '\"');\n\nconst canSendAlone = blockers.length === 0;\nconst subject = email.subject || '(no subject)';\nconst stamp = String(email.messageId || Date.now()).slice(-6).toUpperCase();\n\nreturn [{\n  json: Object.assign({}, email, {\n    reply,\n    confidence,\n    topic,\n    canSendAlone,\n    handledBy: canSendAlone ? 'assistant' : 'support team',\n    reason: canSendAlone\n      ? (modelReason || 'answered straight from the FAQ rows and the product list')\n      : blockers.join('; '),\n    replySubject: /^re:/i.test(subject) ? subject : 'Re: ' + subject,\n    ticketRef: 'NBS-' + stamp,\n    ticketTags: ['needs-human']\n  })\n}];\n"
      },
      "id": "s2-decide-auto-or-person",
      "name": "Decide whether it can be sent without a person",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1320,
        900
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "s2cansendalone01",
              "leftValue": "={{ $json.canSendAlone }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "s2-if-can-answer-alone",
      "name": "Can this one be answered without a person?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1540,
        900
      ]
    },
    {
      "parameters": {
        "resource": "message",
        "operation": "reply",
        "messageId": "={{ $json.messageId }}",
        "emailType": "text",
        "message": "={{ $json.reply }}",
        "options": {
          "senderName": "Northbay Supply support",
          "appendAttribution": false
        }
      },
      "id": "s2-reply-to-customer",
      "name": "Reply to the customer",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        1760,
        720
      ],
      "credentials": {
        "gmailOAuth2": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://northbay-supply-demo.gorgias.com/api/tickets",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ subject: $json.replySubject, channel: 'email', via: 'email', status: 'open', priority: 'normal', external_id: $json.ticketRef, tags: $json.ticketTags, assignee_user: { email: 'nora@northbay-supply.example.com' }, customer: { email: $json.senderEmail, name: $json.senderName }, messages: [ { channel: 'email', via: 'email', from_agent: false, source: { type: 'email', from: { address: $json.senderEmail, name: $json.senderName }, to: [ { address: 'support@northbay-supply.example.com' } ] }, body_text: $json.question } ], meta: { assistant_draft: $json.reply, stopped_because: $json.reason, confidence: $json.confidence, topic: $json.topic, order_named: $json.orderName || 'none' } }) }}",
        "options": {}
      },
      "id": "s2-open-gorgias-ticket",
      "name": "Open a ticket for the support team",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1760,
        1080
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 3000,
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "#support-escalations"
        },
        "text": "=An email needs a person.\n\nFrom: {{ $('Decide whether it can be sent without a person').item.json.senderName }} <{{ $('Decide whether it can be sent without a person').item.json.senderEmail }}>\nSubject: {{ $('Decide whether it can be sent without a person').item.json.subject }}\nTopic: {{ $('Decide whether it can be sent without a person').item.json.topic }} at {{ $('Decide whether it can be sent without a person').item.json.confidence }} confidence\nOrder they named: {{ $('Decide whether it can be sent without a person').item.json.orderName || 'none' }}\nWhy it stopped: {{ $('Decide whether it can be sent without a person').item.json.reason }}\nTicket: https://northbay-supply-demo.gorgias.com/app/ticket/{{ $json.id || $('Decide whether it can be sent without a person').item.json.ticketRef }}\n\nThe assistant already wrote a draft. It is sitting on the ticket, so read it and send it or rewrite it.",
        "otherOptions": {}
      },
      "id": "s2-slack-escalation",
      "name": "Tell the team an email needs them",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1980,
        1080
      ],
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "resource": "record",
        "operation": "create",
        "base": {
          "__rl": true,
          "mode": "id",
          "value": "appNorthbaySupport01"
        },
        "table": {
          "__rl": true,
          "mode": "id",
          "value": "tblConversations"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "value": {
            "Ticket ID": "={{ $('Decide whether it can be sent without a person').item.json.ticketRef }}",
            "Channel": "email",
            "Customer Email": "={{ $('Decide whether it can be sent without a person').item.json.senderEmail }}",
            "Intent": "={{ $('Decide whether it can be sent without a person').item.json.topic }}",
            "Order Name": "={{ $('Decide whether it can be sent without a person').item.json.orderName || 'none' }}",
            "Confidence": "={{ $('Decide whether it can be sent without a person').item.json.confidence }}",
            "Handled By": "={{ $('Decide whether it can be sent without a person').item.json.handledBy }}",
            "Reason": "={{ $('Decide whether it can be sent without a person').item.json.reason }}",
            "Reply Sent": "={{ $('Decide whether it can be sent without a person').item.json.reply }}"
          },
          "schema": [
            {
              "id": "Ticket ID",
              "displayName": "Ticket ID",
              "type": "string",
              "required": false,
              "display": true,
              "removed": false,
              "readOnly": false,
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Channel",
              "displayName": "Channel",
              "type": "string",
              "required": false,
              "display": true,
              "removed": false,
              "readOnly": false,
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Customer Email",
              "displayName": "Customer Email",
              "type": "string",
              "required": false,
              "display": true,
              "removed": false,
              "readOnly": false,
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Intent",
              "displayName": "Intent",
              "type": "string",
              "required": false,
              "display": true,
              "removed": false,
              "readOnly": false,
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Order Name",
              "displayName": "Order Name",
              "type": "string",
              "required": false,
              "display": true,
              "removed": false,
              "readOnly": false,
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Confidence",
              "displayName": "Confidence",
              "type": "string",
              "required": false,
              "display": true,
              "removed": false,
              "readOnly": false,
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Handled By",
              "displayName": "Handled By",
              "type": "string",
              "required": false,
              "display": true,
              "removed": false,
              "readOnly": false,
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Reason",
              "displayName": "Reason",
              "type": "string",
              "required": false,
              "display": true,
              "removed": false,
              "readOnly": false,
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Reply Sent",
              "displayName": "Reply Sent",
              "type": "string",
              "required": false,
              "display": true,
              "removed": false,
              "readOnly": false,
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": true
        },
        "options": {}
      },
      "id": "s2-log-the-conversation",
      "name": "Log the email conversation",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 2.1,
      "position": [
        2200,
        900
      ],
      "credentials": {
        "airtableTokenApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "s3-sticky-returns",
      "name": "What this part does",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -140,
        1400
      ],
      "parameters": {
        "width": 2260,
        "height": 840,
        "color": 6,
        "content": "## Returns and refunds, decided by the shop's own rules\n\n- A return request comes in, the real order gets pulled out of Shopify, and one code node checks it against the 30 day window, the final sale tags, the $150 refund cap and the four reasons that always need a person. The rules sit in a block at the top of that node, so the shop edits its own policy.\n- Clean ones get tagged on the order, emailed their label instructions and answered on the spot. Everything else goes to #support-escalations with the exact rule that stopped it. Both ways get logged in Airtable, and no money moves without a person."
      }
    },
    {
      "id": "s3-return-webhook",
      "name": "A return or refund request comes in",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        1800
      ],
      "parameters": {
        "httpMethod": "POST",
        "path": "northbay/return-request",
        "responseMode": "responseNode",
        "options": {}
      }
    },
    {
      "id": "s3-read-request",
      "name": "Read the return request",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        220,
        1800
      ],
      "parameters": {
        "jsCode": "// Three shapes land on this webhook and they all get read the same way here:\n// the return form on the site, a Gorgias ticket, and a plain JSON post.\nconst raw = $input.first().json;\nconst body = raw.body ?? raw;\nconst ticket = body.ticket ?? body.data?.ticket ?? {};\nconst fields = body.fields ?? body.form_fields ?? body.form ?? {};\n\nfunction pick(names, fallback) {\n  const pools = [body, fields, ticket, ticket.customer || {}, body.customer || {}];\n  for (const name of names) {\n    for (const pool of pools) {\n      const v = pool[name];\n      if (v !== undefined && v !== null && String(v).trim() !== '') return v;\n    }\n  }\n  return fallback;\n}\n\n// The order number, with a leading # taken off so Shopify can match it.\nconst rawOrder = String(pick(['order_number', 'orderNumber', 'order_name', 'order', 'order_id'], ''));\nconst orderName = rawOrder.trim().replace(/^#/, '');\n\nconst email = String(pick(['customer_email', 'email', 'from_email', 'contact_email'], '')).trim().toLowerCase();\n\n// Items can arrive as a list, as one comma separated string, or as a single title.\nconst rawItems = pick(['items', 'item', 'products', 'product', 'line_items'], '');\nlet itemTitles = [];\nif (Array.isArray(rawItems)) {\n  itemTitles = rawItems\n    .map((i) => (typeof i === 'string' ? i : i.title || i.name || i.product || ''))\n    .filter(Boolean);\n} else if (String(rawItems).trim() !== '') {\n  itemTitles = String(rawItems).split(/,|;|\\n/).map((s) => s.trim()).filter(Boolean);\n}\n\nconst statedReason = String(pick(['reason', 'return_reason', 'why', 'message', 'subject', 'body_text'], '')).trim();\n\n// Refund or exchange. Anything that is not clearly an exchange is treated as a refund.\nconst rawResolution = String(pick(['resolution', 'wants', 'outcome', 'refund_or_exchange', 'request_type'], 'refund')).toLowerCase();\nconst resolution = rawResolution.includes('exchange') ? 'exchange' : 'refund';\n\nconst ticketId = String(pick(['ticket_id', 'ticketId', 'id', 'number'], '')) ||\n  'RET-' + (orderName || 'unknown') + '-' + $now.toFormat('yyyyLLdd');\n\nreturn [{\n  json: {\n    ticket_id: ticketId,\n    order_name: orderName,\n    customer_email: email,\n    item_titles: itemTitles,\n    items_text: itemTitles.length ? itemTitles.join(', ') : 'not named on the request',\n    stated_reason: statedReason || 'none given',\n    resolution: resolution,\n    received_at: $now.toISO()\n  }\n}];\n"
      }
    },
    {
      "id": "s3-find-order",
      "name": "Find the order this return is for",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        440,
        1800
      ],
      "parameters": {
        "method": "GET",
        "url": "https://northbay-supply-demo.myshopify.com/admin/api/2024-07/orders.json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "name",
              "value": "={{ $json.order_name }}"
            },
            {
              "name": "status",
              "value": "any"
            }
          ]
        },
        "options": {}
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 3000,
      "onError": "continueErrorOutput"
    },
    {
      "id": "s3-check-policy",
      "name": "Check it against the return policy",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        660,
        1800
      ],
      "parameters": {
        "jsCode": "// ---------- EDITABLE RETURN RULES ----------\n// This block is the whole return policy. Change a line here and the flow changes with it.\n// Nothing below this block needs touching.\nconst RETURN_WINDOW_DAYS = 30;                        // days after delivery a return is still allowed\nconst FINAL_SALE_TAGS = ['final-sale', 'clearance'];  // tags on the order or the item that can never come back\nconst MAX_AUTO_REFUND = 150;                          // dollars. At or over this, a person s

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

Shopify AI Support Desk: Answer, Look Up, Escalate (Demo). Uses anthropic, httpRequest, slack, airtable. Webhook trigger; 48 nodes.

Source: https://github.com/mcruz1799/automation-examples/blob/main/n8n/01-shopify-ai-support-desk/workflow.json — 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 receives insurance quote submissions via a webhook, normalizes and deduplicates them against Airtable and/or Google Sheets, uses OpenAI to score and classify the lead, then routes hot/wa

Airtable, Google Sheets, OpenAI +3
Data & Sheets

This workflow monitors Gmail for messages labeled “Admin”, redacts sensitive data, uses OpenAI to extract structured admin tasks and reminders, skips duplicates by checking Google Sheets and/or Airtab

Gmail Trigger, Airtable, Google Sheets +3
Data & Sheets

01 — Lead Capture → AI Scoring → CRM Pipeline. Uses openAi, gmail, airtable, slack. Webhook trigger; 20 nodes.

OpenAI, Gmail, Airtable +3
Data & Sheets

This guide will walk you through setting up your n8n workflow. By the end, you'll have a fully automated system for managing your recruitment pipeline.

Google Calendar Trigger, Slack, HTTP Request +4
Data & Sheets

This n8n workflow automates the end-to-end client onboarding process: capturing client details, validating emails, assigning tiers, generating welcome packs, creating tasks, notifying teams, archiving

Google Sheets, Gmail, Airtable +5