{
  "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 signs off\nconst ALWAYS_HUMAN_REASONS = ['damaged', 'wrong item', 'never arrived', 'not as described'];\n// ---------- END OF THE EDITABLE BLOCK ----------\n\nconst request = $('Read the return request').first().json;\nconst found = $input.first().json;\nconst order = (found.orders || [])[0] || null;\n\nconst money = (n) => '$' + Number(n || 0).toFixed(2);\n\nconst out = {\n  decision: 'human',\n  reason: '',\n  order_name: request.order_name,\n  days_since: null,\n  refund_value: 0,\n  refund_value_text: '$0.00',\n  customer_email: request.customer_email,\n  item_titles: request.item_titles,\n  items_text: request.items_text,\n  resolution: request.resolution,\n  window_days: RETURN_WINDOW_DAYS,\n  ticket_id: request.ticket_id\n};\n\nif (!order) {\n  out.reason = 'No order in Shopify matches ' + (request.order_name || 'the number they gave');\n  return [{ json: out }];\n}\n\nout.order_name = order.name || ('#' + request.order_name);\n\n// When the parcel actually landed, falling back to when it shipped, then to the order date.\nconst fulfillment = (order.fulfillments || [])[0] || {};\nconst landedAt = fulfillment.updated_at || fulfillment.created_at || order.created_at;\nconst days = Math.floor($now.diff(DateTime.fromISO(landedAt), 'days').days);\nout.days_since = days;\n\n// What the return is worth: the lines they named, or the whole order when they named nothing.\nconst lines = order.line_items || [];\nconst wanted = (request.item_titles || []).map((t) => String(t).toLowerCase()).filter(Boolean);\nconst matched = wanted.length\n  ? lines.filter((li) => {\n      const title = String(li.title || '').toLowerCase();\n      return wanted.some((w) => title.includes(w) || w.includes(title));\n    })\n  : lines;\nconst linesToPrice = matched.length ? matched : lines;\nconst refundValue = linesToPrice.reduce((sum, li) => sum + Number(li.price || 0) * Number(li.quantity || 1), 0);\nout.refund_value = Number(refundValue.toFixed(2));\nout.refund_value_text = money(refundValue);\n\n// Final sale is read off the order tags, and off the item text for stores that mark it on the product.\nconst orderTags = String(order.tags || '').split(',').map((t) => t.trim().toLowerCase()).filter(Boolean);\nconst itemText = linesToPrice.map((li) => [li.title, li.sku, li.variant_title].join(' ')).join(' ').toLowerCase();\nconst isFinalSale = FINAL_SALE_TAGS.some((tag) =>\n  orderTags.includes(tag) || itemText.includes(tag) || itemText.includes(tag.replace(/-/g, ' '))\n);\n\nconst alreadyRefunded = (order.refunds || []).length > 0 ||\n  ['refunded', 'partially_refunded'].includes(String(order.financial_status || '').toLowerCase());\n\nconst reasonText = String(request.stated_reason || '').toLowerCase();\nconst humanReason = ALWAYS_HUMAN_REASONS.find((r) => reasonText.includes(r));\n\nconst insideWindow = days <= RETURN_WINDOW_DAYS;\nconst underCap = out.refund_value < MAX_AUTO_REFUND;\n\nif (alreadyRefunded) {\n  out.reason = 'Shopify already has this order down as refunded, so nothing gets paid twice';\n} else if (humanReason) {\n  out.reason = 'They said \"' + humanReason + '\", and that reason always goes to a person (ALWAYS_HUMAN_REASONS)';\n} else if (isFinalSale) {\n  out.reason = 'The order or the item is final sale (FINAL_SALE_TAGS)';\n} else if (!insideWindow) {\n  out.reason = 'Delivered ' + days + ' days ago and the window is ' + RETURN_WINDOW_DAYS + ' days (RETURN_WINDOW_DAYS)';\n} else if (!underCap) {\n  out.reason = money(out.refund_value) + ' is at or over the ' + money(MAX_AUTO_REFUND) + ' a person has to sign off on (MAX_AUTO_REFUND)';\n} else {\n  out.decision = 'approve';\n  out.reason = 'Delivered ' + days + ' days ago, inside the ' + RETURN_WINDOW_DAYS + ' day window, ' +\n    money(out.refund_value) + ' under the ' + money(MAX_AUTO_REFUND) + ' cap, nothing final sale, and no refund on it yet';\n}\n\nreturn [{ json: out }];\n"
      }
    },
    {
      "id": "s3-policy-if",
      "name": "Does it clear the return policy on its own?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        880,
        1800
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "s3-cond-approve",
              "leftValue": "={{ $json.decision === 'approve' }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "s3-note-order",
      "name": "Note the approved return on the order",
      "type": "n8n-nodes-base.shopify",
      "typeVersion": 1,
      "position": [
        1100,
        1620
      ],
      "parameters": {
        "authentication": "accessToken",
        "resource": "order",
        "operation": "update",
        "orderId": "={{ $('Find the order this return is for').first().json.orders[0].id }}",
        "updateFields": {
          "tags": "return-approved",
          "note": "=Return approved on {{ $now.toFormat('yyyy-LL-dd') }}. {{ $('Check it against the return policy').first().json.reason }}. Refund of {{ $('Check it against the return policy').first().json.refund_value_text }} goes out once the box is checked in at the warehouse."
        }
      },
      "credentials": {
        "shopifyAccessTokenApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "s3-send-instructions",
      "name": "Send the return instructions",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        1320,
        1620
      ],
      "parameters": {
        "resource": "message",
        "operation": "send",
        "sendTo": "={{ $('Check it against the return policy').item.json.customer_email }}",
        "subject": "=Your return on order {{ $('Check it against the return policy').item.json.order_name }} is approved",
        "emailType": "text",
        "message": "=Hi,\n\nGood news on order {{ $('Check it against the return policy').item.json.order_name }}. Your return is approved.\n\nIt landed with you {{ $('Check it against the return policy').item.json.days_since }} days ago and our window is {{ $('Check it against the return policy').item.json.window_days }} days, so you are well inside it.\n\nWhat to send back: {{ $('Check it against the return policy').item.json.items_text }}\n\nYour prepaid label is on your order page at northbay-supply.example.com/returns. Print it, tape it on the box, and drop it at any USPS counter. If you would rather not print, send the box to:\n\nNorthbay Supply Co. Returns\n1420 Harbor Line Rd\nBellingham, WA 98225\n\nWrite your order number on the outside either way.\n\nYour refund of {{ $('Check it against the return policy').item.json.refund_value_text }} goes back to the card you paid with once the parcel is checked in at the warehouse. That check in happens the day it arrives, and the bank takes a few days after that.\n\nAnything else, just reply here.\n\nNorthbay Supply Co.\nsupport@northbay-supply.example.com",
        "options": {
          "senderName": "Northbay Supply Co.",
          "replyTo": "support@northbay-supply.example.com",
          "appendAttribution": false
        }
      },
      "credentials": {
        "gmailOAuth2": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "s3-respond-approved",
      "name": "Confirm the return is approved",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        1540,
        1620
      ],
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ status: 'approved', order: $('Check it against the return policy').first().json.order_name + '', next_step: 'Label instructions are on the way to ' + $('Check it against the return policy').first().json.customer_email + '. Refund of ' + $('Check it against the return policy').first().json.refund_value_text + ' goes out once the box is checked in.' }) }}",
        "options": {
          "responseCode": 200
        }
      }
    },
    {
      "id": "s3-escalate-return",
      "name": "Send it to a person to decide",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1100,
        1980
      ],
      "parameters": {
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "#support-escalations"
        },
        "text": "=A return needs a person.\n\nOrder: {{ $('Read the return request').first().json.order_name }}\nCustomer: {{ $('Read the return request').first().json.customer_email }}\nItem: {{ $('Read the return request').first().json.items_text }}\nThey want: {{ $('Read the return request').first().json.resolution }}\nReason they gave: {{ $('Read the return request').first().json.stated_reason }}\nDays since delivery: {{ $('Check it against the return policy').isExecuted ? $('Check it against the return policy').first().json.days_since : 'not known, Shopify did not answer' }}\nRefund value: {{ $('Check it against the return policy').isExecuted ? $('Check it against the return policy').first().json.refund_value_text : 'not worked out yet' }}\nWhat stopped it: {{ $('Check it against the return policy').isExecuted ? $('Check it against the return policy').first().json.reason : 'Shopify did not answer after 3 tries, so nothing was checked against the policy' }}\n\nNothing has been refunded and nothing has been sent to them yet. Make the call and reply to {{ $('Read the return request').first().json.customer_email }}.",
        "otherOptions": {}
      },
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "s3-respond-review",
      "name": "Tell them a person is reviewing it",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        1320,
        1980
      ],
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ status: 'in_review', order: $('Read the return request').first().json.order_name + '', message: 'Got it. Someone on the support team is looking at this return today and will email ' + $('Read the return request').first().json.customer_email + ' with an answer.' }) }}",
        "options": {
          "responseCode": 200
        }
      }
    },
    {
      "id": "s3-log-return-decision",
      "name": "Log the return decision",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 2.1,
      "position": [
        1780,
        1800
      ],
      "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": "={{ $('Read the return request').first().json.ticket_id }}",
            "Channel": "returns",
            "Customer Email": "={{ $('Read the return request').first().json.customer_email }}",
            "Intent": "return_request",
            "Order Name": "={{ $('Check it against the return policy').isExecuted ? $('Check it against the return policy').first().json.order_name : $('Read the return request').first().json.order_name }}",
            "Confidence": "={{ $('Check it against the return policy').isExecuted ? 1 : 0 }}",
            "Handled By": "={{ $('Check it against the return policy').isExecuted && $('Check it against the return policy').first().json.decision === 'approve' ? 'Handled on its own' : 'Support team' }}",
            "Reason": "={{ $('Check it against the return policy').isExecuted ? $('Check it against the return policy').first().json.reason : 'Shopify did not answer when we asked for the order, so a person picked it up' }}",
            "Reply Sent": "={{ $('Send the return instructions').isExecuted ? 'Return approved email sent to ' + $('Read the return request').first().json.customer_email : 'Nothing sent yet, a person is writing the 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": {}
      },
      "credentials": {
        "airtableTokenApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "s4-sticky-weekly-read",
      "name": "What this section does",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -140,
        2480
      ],
      "parameters": {
        "width": 1580,
        "height": 680,
        "color": 4,
        "content": "## The weekly read on what the assistant is missing\n\n- Every Monday at 8 the flow reads last week's conversation log, counts what it answered on its own against what went to a person, and has Claude group the questions that kept landing on the team.\n- Out the other end: a short digest in #support-desk, and the suggested answers dropped into the FAQ sheet as draft rows. Approve them and the assistant covers more of next week by itself."
      }
    },
    {
      "id": "s4-monday-trigger",
      "name": "Every Monday morning",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        2700
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "weeks",
              "weeksInterval": 1,
              "triggerAtDay": [
                1
              ],
              "triggerAtHour": 8,
              "triggerAtMinute": 0
            }
          ]
        }
      }
    },
    {
      "id": "s4-read-conversations",
      "name": "Read last week's conversations",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 2.1,
      "position": [
        220,
        2700
      ],
      "parameters": {
        "resource": "record",
        "operation": "search",
        "base": {
          "__rl": true,
          "mode": "id",
          "value": "appNorthbaySupport01"
        },
        "table": {
          "__rl": true,
          "mode": "id",
          "value": "tblConversations"
        },
        "returnAll": false,
        "limit": 200,
        "filterByFormula": "IS_AFTER({Created}, DATEADD(TODAY(), -7, 'days'))",
        "options": {}
      },
      "credentials": {
        "airtableTokenApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "s4-count-the-week",
      "name": "Count what happened and pull out the ones a person had to take",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        2700
      ],
      "parameters": {
        "jsCode": "// Last week of the support desk, counted once, here.\n// Everything the model is asked to talk about is worked out in this node so the\n// model never has to add anything up on its own.\n\nconst CONFIDENCE_FLOOR = 0.8;          // below this the assistant hands off\nconst MAX_ESCALATED_SENT = 60;         // keep the prompt small\n\n// Airtable v2 hands fields back at the top level, older shapes nest them under .fields\nconst rows = $input.all().map((i) => (i.json && i.json.fields ? { ...i.json.fields, id: i.json.id } : i.json));\n\nconst total = rows.length;\nconst intentCounts = {};\nconst reasonCounts = {};\nconst escalated = [];\nlet handledOnItsOwn = 0;\nlet sentToPerson = 0;\nlet confidenceSum = 0;\nlet confidenceCount = 0;\n\nfor (const r of rows) {\n  const intent = String(r['Intent'] || 'Unclear').trim() || 'Unclear';\n  intentCounts[intent] = (intentCounts[intent] || 0) + 1;\n\n  const rawConfidence = r['Confidence'];\n  const confidence = Number(rawConfidence);\n  if (rawConfidence !== null && rawConfidence !== undefined && rawConfidence !== '' && !Number.isNaN(confidence)) {\n    confidenceSum += confidence;\n    confidenceCount += 1;\n  }\n\n  const handledBy = String(r['Handled By'] || '').trim();\n  const wentToAPerson = handledBy.toLowerCase() !== 'assistant';\n\n  if (!wentToAPerson) {\n    handledOnItsOwn += 1;\n    continue;\n  }\n\n  sentToPerson += 1;\n  const reason = String(r['Reason'] || 'No reason written down').trim();\n  reasonCounts[reason] = (reasonCounts[reason] || 0) + 1;\n\n  escalated.push({\n    ticket: String(r['Ticket ID'] || '').trim(),\n    channel: String(r['Channel'] || '').trim(),\n    intent,\n    reason,\n    question: String(r['Customer Question'] || r['Question'] || reason || '').trim().slice(0, 240),\n    confidence: Number.isNaN(confidence) ? null : confidence,\n    handledBy,\n  });\n}\n\nconst pct = (part) => (total ? Math.round((part / total) * 100) : 0);\n\nconst intentSplit = Object.entries(intentCounts)\n  .map(([intent, count]) => ({ intent, count, share: pct(count) }))\n  .sort((a, b) => b.count - a.count);\n\nconst topReasons = Object.entries(reasonCounts)\n  .map(([reason, count]) => ({ reason, count }))\n  .sort((a, b) => b.count - a.count)\n  .slice(0, 10);\n\nconst escalatedSample = escalated.slice(0, MAX_ESCALATED_SENT);\n\nconst intentSplitText = intentSplit.length\n  ? intentSplit.map((r) => `  ${r.intent}: ${r.count} (${r.share}%)`).join('\\n')\n  : '  nothing logged';\n\nconst escalatedText = escalatedSample.length\n  ? escalatedSample\n      .map((e, i) => `${i + 1}. [${e.intent}] ${e.question || 'no question text logged'}\\n   why it went to a person: ${e.reason}`)\n      .join('\\n')\n  : 'nothing went to a person last week';\n\nconst averageConfidence = confidenceCount ? Number((confidenceSum / confidenceCount).toFixed(2)) : 0;\n\nreturn [\n  {\n    json: {\n      weekLabel: `${$now.minus({ days: 7 }).toFormat('LLL d')} to ${$now.minus({ days: 1 }).toFormat('LLL d')}`,\n      generatedOn: $now.toFormat('yyyy-LL-dd'),\n      totalConversations: total,\n      handledOnItsOwn,\n      handledOnItsOwnPct: pct(handledOnItsOwn),\n      sentToPerson,\n      sentToPersonPct: pct(sentToPerson),\n      averageConfidence,\n      confidenceFloor: CONFIDENCE_FLOOR,\n      intentSplit,\n      intentSplitText,\n      topReasons,\n      escalatedSampleCount: escalatedSample.length,\n      escalatedTotal: escalated.length,\n      escalatedText,\n      escalated: escalatedSample,\n    },\n  },\n];\n"
      }
    },
    {
      "id": "s4-ask-claude",
      "name": "Ask Claude which answers are worth writing down",
      "type": "@n8n/n8n-nodes-langchain.anthropic",
      "typeVersion": 1,
      "position": [
        660,
        2700
      ],
      "parameters": {
        "resource": "text",
        "operation": "message",
        "modelId": {
          "__rl": true,
          "mode": "id",
          "value": "claude-sonnet-5"
        },
        "messages": {
          "values": [
            {
              "role": "user",
              "content": "=Here is last week at the Northbay Supply Co. support desk. Every number below is final.\n\nWeek: {{ $json.weekLabel }}\nConversations: {{ $json.totalConversations }}\nHandled by the assistant on its own: {{ $json.handledOnItsOwn }} ({{ $json.handledOnItsOwnPct }}%)\nSent to a person: {{ $json.sentToPerson }} ({{ $json.sentToPersonPct }}%)\nAverage confidence: {{ $json.averageConfidence }}\nConfidence floor the assistant hands off at: {{ $json.confidenceFloor }}\n\nBy intent:\n{{ $json.intentSplitText }}\n\nThe ones a person had to take, {{ $json.escalatedSampleCount }} of {{ $json.sentToPerson }} shown:\n{{ $json.escalatedText }}\n\nGroup those into the answers worth writing down, and write the answer for each one. Return the JSON object only."
            }
          ]
        },
        "simplify": false,
        "options": {
          "system": "You read one week of customer service conversations for Northbay Supply Co., an outdoor gear shop, and you say which answers are worth writing down so the assistant can handle more of them by itself.\n\nAbout the numbers:\n- Every figure you need is already worked out and handed to you in the message.\n- Do not do arithmetic. No adding, no averaging, no percentages of your own, no rounding.\n- Copy any figure you use exactly as it is written.\n- The count on a theme is how many of the listed questions belong to it, and nothing else.\n- Never invent a number, a customer, an order or a date.\n\nWhat to give back. Reply with a JSON object and nothing else. No code fence, no sentence before or after it. Use exactly these keys:\n{\"headline\": \"\", \"themes\": [{\"theme\": \"\", \"count\": 0, \"suggested_answer\": \"\"}], \"watch_outs\": [\"\"]}\n\nheadline is one line on what last week looked like.\nthemes holds at most 5 groups, biggest first. A theme is a question the team had to answer by hand more than once. suggested_answer is the answer itself, written the way support would say it to a customer, short enough to paste into a reply.\nwatch_outs holds anything a person should know about before it becomes a problem. Leave it empty if there is nothing.\n\nVOICE RULES for every word you write:\nWrite like a person. Plain words. No corporate phrases. No emojis. No em dashes. Never say reach out. No three-item lists for rhythm. Short sentences.",
          "maxTokens": 1500
        }
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 3000,
      "credentials": {
        "anthropicApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "s4-lay-out-digest",
      "name": "Lay the digest out for Slack",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        2700
      ],
      "parameters": {
        "jsCode": "// Turn Claude's JSON into the plain block that gets posted in Slack,\n// and hand one row per theme to the FAQ sheet.\n\nconst FIGURES_NODE = 'Count what happened and pull out the ones a person had to take';\nconst figures = $(FIGURES_NODE).first().json;\n\nconst raw = $input.first().json;\nconst modelText = raw?.content?.[0]?.text ?? raw?.text ?? raw?.output ?? '';\n\nlet read;\ntry {\n  read = JSON.parse(String(modelText).replace(/```json/gi, '').replace(/```/g, '').trim());\n} catch (err) {\n  read = {\n    headline: 'The write-up did not come back readable this week. The numbers below are still good.',\n    themes: [],\n    watch_outs: [],\n  };\n}\n\nconst themes = Array.isArray(read.themes) ? read.themes : [];\nconst watchOuts = Array.isArray(read.watch_outs) ? read.watch_outs : [];\n\nconst lines = [];\nlines.push(`Weekly support read for Northbay Supply Co., ${figures.weekLabel}`);\nlines.push('');\nif (read.headline) {\n  lines.push(String(read.headline).trim());\n  lines.push('');\n}\nlines.push('The numbers');\nlines.push(`  Conversations: ${figures.totalConversations}`);\nlines.push(`  Handled on its own: ${figures.handledOnItsOwn} (${figures.handledOnItsOwnPct}%)`);\nlines.push(`  Sent to a person: ${figures.sentToPerson} (${figures.sentToPersonPct}%)`);\nlines.push(`  Average confidence: ${figures.averageConfidence} against a floor of ${figures.confidenceFloor}`);\nlines.push('');\nlines.push('By intent');\nlines.push(figures.intentSplitText);\nlines.push('');\n\nif (themes.length) {\n  lines.push('What kept landing on a person');\n  themes.forEach((t, i) => {\n    const count = t.count === null || t.count === undefined ? '' : ` (${t.count})`;\n    lines.push(`  ${i + 1}. ${String(t.theme || 'Untitled').trim()}${count}`);\n    if (t.suggested_answer) {\n      lines.push(`     Answer to add: ${String(t.suggested_answer).trim()}`);\n    }\n  });\n  lines.push('');\n} else {\n  lines.push('Nothing repeated enough to be worth a new answer this week.');\n  lines.push('');\n}\n\nif (watchOuts.length) {\n  lines.push('Watch out for');\n  watchOuts.forEach((w) => lines.push(`  - ${String(w).trim()}`));\n  lines.push('');\n}\n\nlines.push(\n  themes.length\n    ? 'The answers above are already sitting in the FAQ sheet as draft rows. Approve the ones you want and the assistant starts using them next week.'\n    : 'Nothing new went into the FAQ sheet this week.'\n);\n\nconst digestText = lines.join('\\n');\nconst lastUpdated = figures.generatedOn;\n\nif (!themes.length) {\n  return [\n    {\n      json: {\n        digestText,\n        themeCount: 0,\n        topic: 'No new answers this week',\n        question: 'Nothing that went to a person repeated often enough to write down',\n        answer: 'Leave the FAQ sheet as it is. Nothing to approve.',\n        lastUpdated,\n      },\n    },\n  ];\n}\n\nreturn themes.map((t) => ({\n  json: {\n    digestText,\n    themeCount: themes.length,\n    topic: String(t.theme || 'Untitled').trim(),\n    question: String(t.theme || '').trim(),\n    answer: String(t.suggested_answer || '').trim(),\n    lastUpdated,\n  },\n}));\n"
      }
    },
    {
      "id": "s4-post-digest",
      "name": "Post the weekly digest",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1100,
        2700
      ],
      "parameters": {
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "#support-desk"
        },
        "text": "={{ $json.digestText }}",
        "otherOptions": {}
      },
      "executeOnce": true,
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "s4-draft-faq-rows",
      "name": "Add the suggested answers to the FAQ sheet for review",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        1100,
        2900
      ],
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "1NbSupplyFaqPolicies2026Demo"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "FAQ & Policies"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "value": {
            "Topic": "={{ $json.topic }}",
            "Question": "={{ $json.question }}",
            "Answer": "=DRAFT for review: {{ $json.answer }}",
            "Last Updated": "={{ $json.lastUpdated }}"
          },
          "schema": [
            {
              "id": "Topic",
              "displayName": "Topic",
              "type": "string",
              "required": false,
              "display": true,
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Question",
              "displayName": "Question",
              "type": "string",
              "required": false,
              "display": true,
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Answer",
              "displayName": "Answer",
              "type": "string",
              "required": false,
              "display": true,
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Last Updated",
              "displayName": "Last Updated",
              "type": "string",
              "required": false,
              "display": true,
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": true
        },
        "options": {}
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      }
    }
  ],
  "connections": {
    "A customer question arrives from chat or the helpdesk": {
      "main": [
        [
          {
            "node": "Put every question into the same shape",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Put every question into the same shape": {
      "main": [
        [
          {
            "node": "Ask Claude what the customer needs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Ask Claude what the customer needs": {
      "main": [
        [
          {
            "node": "Read what Claude found",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read what Claude found": {
      "main": [
        [
          {
            "node": "Find the order in Shopify",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Find the order in Shopify": {
      "main": [
        [
          {
            "node": "Pull out the order facts worth quoting",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Post it in the support channel for a person",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pull out the order facts worth quoting": {
      "main": [
        [
          {
            "node": "Write the reply using only the order facts",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Write the reply using only the order facts": {
      "main": [
        [
          {
            "node": "Check the answer is safe to send on its own",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check the answer is safe to send on its own": {
      "main": [
        [
          {
            "node": "Can this one go out on its own?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Can this one go out on its own?": {
      "main": [
        [
          {
            "node": "Send the answer back to the customer",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Post it in the support channel for a person",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send the answer back to the customer": {
      "main": [
        [
          {
            "node": "Log the conversation",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Post it in the support channel for a person": {
      "main": [
        [
          {
            "node": "Tell the customer a person is picking it up",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Tell the customer a person is picking it up": {
      "main": [
        [
          {
            "node": "Log the conversation",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "A new email lands in the support inbox": {
      "main": [
        [
          {
            "node": "Pick out the question and who sent it",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick out the question and who sent it": {
      "main": [
        [
          {
            "node": "Read the shop's FAQ and policies",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read the shop's FAQ and policies": {
      "main": [
        [
          {
            "node": "Look up the matching products",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Look up the matching products": {
      "main": [
        [
          {
            "node": "Build one answer sheet for the assistant",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build one answer sheet for the assistant": {
      "main": [
        [
          {
            "node": "Write the reply from the shop's own answers",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Write the reply from the shop's own answers": {
      "main": [
        [
          {
            "node": "Decide whether it can be sent without a person",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Decide whether it can be sent without a person": {
      "main": [
        [
          {
            "node": "Can this one be answered without a person?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Can this one be answered without a person?": {
      "main": [
        [
          {
            "node": "Reply to the customer",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Open a ticket for the support team",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reply to the customer": {
      "main": [
        [
          {
            "node": "Log the email conversation",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Open a ticket for the support team": {
      "main": [
        [
          {
            "node": "Tell the team an email needs them",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Tell the team an email needs them": {
      "main": [
        [
          {
            "node": "Log the email conversation",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "A return or refund request comes in": {
      "main": [
        [
          {
            "node": "Read the return request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read the return request": {
      "main": [
        [
          {
            "node": "Find the order this return is for",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Find the order this return is for": {
      "main": [
        [
          {
            "node": "Check it against the return policy",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Send it to a person to decide",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check it against the return policy": {
      "main": [
        [
          {
            "node": "Does it clear the return policy on its own?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Does it clear the return policy on its own?": {
      "main": [
        [
          {
            "node": "Note the approved return on the order",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Send it to a person to decide",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Note the approved return on the order": {
      "main": [
        [
          {
            "node": "Send the return instructions",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send the return instructions": {
      "main": [
        [
          {
            "node": "Confirm the return is approved",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Confirm the return is approved": {
      "main": [
        [
          {
            "node": "Log the return decision",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send it to a person to decide": {
      "main": [
        [
          {
            "node": "Tell them a person is reviewing it",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Tell them a person is reviewing it": {
      "main": [
        [
          {
            "node": "Log the return decision",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Every Monday morning": {
      "main": [
        [
          {
            "node": "Read last week's conversations",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read last week's conversations": {
      "main": [
        [
          {
            "node": "Count what happened and pull out the ones a person had to take",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Count what happened and pull out the ones a person had to take": {
      "main": [
        [
          {
            "node": "Ask Claude which answers are worth writing down",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Ask Claude which answers are worth writing down": {
      "main": [
        [
          {
            "node": "Lay the digest out for Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Lay the digest out for Slack": {
      "main": [
        [
          {
            "node": "Post the weekly digest",
            "type": "main",
            "index": 0
          },
          {
            "node": "Add the suggested answers to the FAQ sheet for review",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "active": false
}