{"meta":{"instanceId":"09423a3357ff64bdcc082268b9d577001317edbe377a3ccfb0b131ffb9554b30"},"nodes":[{"id":"6897a614-cd5a-4e76-99fb-7094b4692dd2","name":"Google Gemini Chat Model","type":"@n8n/n8n-nodes-langchain.lmChatGoogleGemini","position":[2352,464],"parameters":{"options":{}},"typeVersion":1},{"id":"1b011a66-c09a-4a6c-b666-89a5301f54cc","name":"Reply to a message","type":"n8n-nodes-base.gmail","position":[3184,240],"webhookId":"597ba693-ad1e-4a19-9c41-8f8a82e7849f","parameters":{"message":"={{ $('Draft Reply (AI Agent)').item.json.output }}","options":{"appendAttribution":false},"emailType":"text","messageId":"={{ $('Watch Gmail (New Inbound)').first().json.threadId }}","operation":"reply"},"typeVersion":2.1},{"id":"287d6e80-9e28-4188-87a8-c46def152c1e","name":"Watch Gmail (New Inbound)","type":"n8n-nodes-base.gmailTrigger","position":[544,256],"parameters":{"filters":{},"pollTimes":{"item":[{"mode":"everyMinute"}]}},"typeVersion":1.3},{"id":"4b25d17f-ce32-47d2-a27d-ca52c68a5e46","name":"Filter: Allowed Sender","type":"n8n-nodes-base.filter","position":[752,256],"parameters":{"options":{},"conditions":{"options":{"version":2,"leftValue":"","caseSensitive":true,"typeValidation":"strict"},"combinator":"and","conditions":[{"id":"c0511cfb-d540-4b31-b665-f6038d6f8bbe","operator":{"type":"string","operation":"notContains"},"leftValue":"={{ $json.From }}","rightValue":"n8n.io"}]}},"typeVersion":2.2},{"id":"61b5b40e-988e-4482-b729-0669e9081fb2","name":"Draft Reply (AI Agent)","type":"@n8n/n8n-nodes-langchain.agent","position":[2352,256],"parameters":{"text":"=You are a helpful, concise customer support/sales assistant. Draft a ready-to-send email reply.\n\nDO NOT output JSON, arrays, or anything under CONTEXT. Only output the email.\n\n# INPUTS\n\nMy name (for signature): John Bolton\nFrom: {{$('Watch Gmail (New Inbound)').first().json.From}}\nSubject: {{$('Watch Gmail (New Inbound)').first().json.Subject}}\nCustomer message:\n{{$('Watch Gmail (New Inbound)').first().json.snippet}}\n\n# CONTEXT (do not quote or restate; summarize only if helpful)\nContact (HubSpot JSON):\n{{ JSON.stringify($('Find Contact by Email').first().json.properties || {}, null, 2) }}\n\nCompanies (JSON, may be empty):\n{{ JSON.stringify($json.companies || []) }}\n\nDeals (JSON, may be empty):\n{{ JSON.stringify($json.deals || []) }}\n\nTickets (JSON, may be empty):\n{{ JSON.stringify($json.tickets || []) }}\n\n# WHAT TO DO\n- Acknowledge the sender and the exact topic in the Subject/body.\n- Answer their request directly and succinctly.\n- Offer 1–2 clear next steps or a single CTA.\n- Personalize using safe context only:\n  - Use contact name/company if present.\n  - If deals exist, mention at most the 1–2 most relevant (name, stage, amount, close date). Ignore IDs/owner/pipeline/internal fields.\n  - If tickets exist, reference subject/status briefly if relevant.\n- If context is missing, write a generic but professional reply (do not invent facts).\n\n# TONE\nFriendly, professional, plain language. Short paragraphs or brief bullets.\n\n# OUTPUT FORMAT (no extra commentary, no subject, just the email body)\n- Greeting with the person’s name if available.\n- 2–5 sentences answering the question; bullets allowed for steps.\n- Optional one-line context (deal/ticket) if helpful.\n- One clear CTA.\n- Polite sign-off with a sender name placeholder.\n\n# CONSTRAINTS\n- Never expose IDs, raw JSON, or internal property names.\n- Keep under ~150 words unless necessary.\n- If anything is unclear, end with exactly one clarifying question.\n\nGenerate the reply now.\n","options":{},"promptType":"define"},"typeVersion":2.2},{"id":"81616dff-2202-4e66-9c2f-7e93403bf909","name":"Find Contact by Email","type":"n8n-nodes-base.hubspot","position":[1040,256],"parameters":{"operation":"search","authentication":"oAuth2","filterGroupsUi":{"filterGroupsValues":[{"filtersUi":{"filterValues":[{"value":"={{ String($json.From || '').match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}/i)?.[0] || '' }}","propertyName":"email|string"}]}}]},"additionalFields":{"properties":["email","firstname","lastname","jobtitle","company","country","state","city","hs_language","phone","mobilephone","lifecyclestage","hs_lead_status","hubspot_owner_id","hs_email_last_open_date","hs_email_last_reply_date","hs_latest_meeting_activity","hs_sequences_is_enrolled","hs_sequences_enrolled_count","createdate","hs_lastmodifieddate","hs_timezone","notes_last_contacted","hs_object_id"]}},"typeVersion":2.1},{"id":"6e5e8866-223c-4cdc-b201-7e804d47b01d","name":"Set Record Types","type":"n8n-nodes-base.code","position":[1248,256],"parameters":{"jsCode":"const input = $input.first();\nlet records = Array.isArray(input?.json?.records)\n  ? input.json.records\n  : [\"deals\",\"companies\",\"tickets\"];\n\nreturn records.map(name => ({ json: { record: name } }));"},"typeVersion":2},{"id":"94281ea8-a451-42a9-9fb6-b90e4b5dc42a","name":"List Contact Associations","type":"n8n-nodes-base.httpRequest","position":[1456,256],"parameters":{"url":"=https://api.hubapi.com/crm/v4/objects/contacts/{{ $('Find Contact by Email').item.json.id }}/associations/{{ $json.record }}","options":{"response":{}},"authentication":"predefinedCredentialType","nodeCredentialType":"hubspotOAuth2Api"},"typeVersion":4.2},{"id":"70d7c8e3-bbc5-4be2-bc65-c5c168bcba84","name":"Build Batch Read Requests","type":"n8n-nodes-base.code","position":[1664,256],"parameters":{"jsCode":"// Build batch/read requests for only: deals, companies, tickets\n\nconst PROPS = {\n  deals: [\n    \"dealname\",\n    \"amount\",\n    \"dealstage\",\n    \"pipeline\",\n    \"closedate\",\n    \"hubspot_owner_id\",\n    \"hs_lastmodifieddate\",\n  ],\n  companies: [\n    \"name\",\n    \"domain\",\n    \"industry\",\n    \"numberofemployees\",\n    \"annualrevenue\",\n    \"website\",\n    \"phone\",\n    \"city\",\n    \"state\",\n    \"country\",\n    \"hubspot_owner_id\",\n    \"createdate\",\n    \"hs_lastmodifieddate\",\n  ],\n  tickets: [\n    \"hs_ticket_id\",\n    \"subject\",\n    \"content\",\n    \"hs_pipeline\",\n    \"hs_pipeline_stage\",\n    \"hs_ticket_priority\",\n    \"hs_lastmodifieddate\",\n    \"createdate\",\n    \"closed_date\",\n  ],\n};\n\n// If the upstream node emits these three in order, this helps infer the object when not provided\nconst ORDER = [\"deals\", \"companies\", \"tickets\"];\n\nfunction toBatchRead(item, idx) {\n  const object = item.json.object || item.json.record || ORDER[idx];\n\n  const results = Array.isArray(item.json.results) ? item.json.results : [];\n  const ids = results.map(r => String(r.toObjectId)).filter(Boolean);\n\n  return {\n    json: {\n      object,\n      url: `https://api.hubapi.com/crm/v3/objects/${object}/batch/read`,\n      method: \"POST\",\n      headers: { \"content-type\": \"application/json\" },\n      body: {\n        properties: PROPS[object] || [],\n        archived: false,\n        inputs: ids.map(id => ({ id })),\n      },\n      hasInputs: ids.length > 0,\n      count: ids.length,\n    },\n  };\n}\n\nreturn $input.all().map(toBatchRead);\n"},"typeVersion":2},{"id":"ee30292a-fce0-4e18-a422-3d7a58b82e4e","name":"Batch Read Objects","type":"n8n-nodes-base.httpRequest","position":[1888,256],"parameters":{"url":"={{ $json.url }}","body":"={{ $json.body }}","method":"POST","options":{"response":{}},"sendBody":true,"contentType":"raw","authentication":"predefinedCredentialType","rawContentType":"={{ $json.headers['content-type'] }}","nodeCredentialType":"hubspotOAuth2Api"},"typeVersion":4.2},{"id":"e588dad3-5cdc-47f8-b180-d97d8e0bbb0a","name":"Normalize CRM Context for LLM","type":"n8n-nodes-base.code","position":[2096,256],"parameters":{"jsCode":"// n8n Code node (JavaScript)\n// Input: three items (HubSpot batch/read responses) for deals, companies, tickets (order unknown)\n// Output: a single consolidated item with cleaned, LLM-ready fields\n\nconst items = $input.all().map(i => i.json);\n\n// --- helpers ---\nconst isNonEmpty = v => v !== null && v !== undefined && v !== '';\nconst stripNulls = obj =>\n  Object.fromEntries(Object.entries(obj).filter(([, v]) => isNonEmpty(v)));\n\nfunction detectType(block) {\n  const first = block?.results?.[0]?.properties || {};\n  if ('dealname' in first || 'dealstage' in first) return 'deals';\n  if ('hs_ticket_id' in first || 'hs_pipeline' in first) return 'tickets';\n  if ('name' in first || 'industry' in first) return 'companies';\n  return 'unknown';\n}\n\nfunction mapDeal(p) {\n  return stripNulls({\n    id: p.hs_object_id || p.id,\n    name: p.dealname,\n    stage: p.dealstage,\n    amount: isNonEmpty(p.amount) ? Number(p.amount) : undefined,\n    pipeline: p.pipeline,\n    closeDate: p.closedate,\n    ownerId: p.hubspot_owner_id,\n    createdAt: p.createdate,\n    lastUpdatedAt: p.hs_lastmodifieddate,\n  });\n}\n\nfunction mapCompany(p) {\n  // Derive a simple location string when possible\n  const parts = [p.city, p.state, p.country].filter(isNonEmpty);\n  const hq = parts.length ? parts.join(', ') : undefined;\n\n  return stripNulls({\n    id: p.hs_object_id || p.id,\n    name: p.name,\n    domain: p.domain,\n    website: p.website,\n    phone: p.phone,\n    industry: p.industry,\n    employees: isNonEmpty(p.numberofemployees) ? Number(p.numberofemployees) : undefined,\n    annualRevenue: isNonEmpty(p.annualrevenue) ? Number(p.annualrevenue) : undefined,\n    headquarters: hq,\n    ownerId: p.hubspot_owner_id,\n    createdAt: p.createdate,\n    lastUpdatedAt: p.hs_lastmodifieddate,\n  });\n}\n\nfunction mapTicket(p) {\n  return stripNulls({\n    id: p.hs_ticket_id || p.hs_object_id || p.id,\n    subject: p.subject,\n    description: p.content,\n    pipelineId: p.hs_pipeline,\n    stageId: p.hs_pipeline_stage,\n    priority: p.hs_ticket_priority,\n    createdAt: p.createdate,\n    lastUpdatedAt: p.hs_lastmodifieddate,\n    closedDate: p.closed_date,\n  });\n}\n\n// --- collect ---\nconst out = { deals: [], companies: [], tickets: [] };\n\nfor (const block of items) {\n  const t = detectType(block);\n  const rows = Array.isArray(block.results) ? block.results : [];\n  if (t === 'deals') {\n    out.deals = rows.map(r => mapDeal(r.properties || {})).filter(o => Object.keys(o).length);\n  } else if (t === 'companies') {\n    out.companies = rows.map(r => mapCompany(r.properties || {})).filter(o => Object.keys(o).length);\n  } else if (t === 'tickets') {\n    out.tickets = rows.map(r => mapTicket(r.properties || {})).filter(o => Object.keys(o).length);\n  }\n}\n\n// Optional high-level summary for the LLM\nout.summary = {\n  dealCount: out.deals.length,\n  companyCount: out.companies.length,\n  ticketCount: out.tickets.length,\n};\n\n// Emit a single consolidated item\nreturn [{ json: out }];\n"},"typeVersion":2},{"id":"04448b5d-ac9c-417b-9246-3853e94303f0","name":"Wait for Response - Approve Auto-Reply","type":"n8n-nodes-base.slack","position":[2768,256],"webhookId":"ffb81691-54b1-43da-8b71-a4c45362901b","parameters":{"select":"channel","message":"={{ $('Watch Gmail (New Inbound)').first().json.From }} sent you the following message:\n\n{{ $('Watch Gmail (New Inbound)').first().json.snippet }}\n\n\nHere is an auto-generated reply (press \"Approve\" to send it):\n\n{{ $json.output }}","options":{"limitWaitTime":{"values":{"resumeUnit":"days"}}},"channelId":{"__rl":true,"mode":"list","value":"C09H7HTHRMG","cachedResultName":"all-n8n-slack-test"},"operation":"sendAndWait","authentication":"oAuth2"},"typeVersion":2.3},{"id":"464de728-acf5-4664-9469-f4f660d29ec6","name":"If Approved?","type":"n8n-nodes-base.if","position":[2976,256],"parameters":{"options":{},"conditions":{"options":{"version":2,"leftValue":"","caseSensitive":true,"typeValidation":"strict"},"combinator":"and","conditions":[{"id":"9313939d-ed39-4a91-b0c6-18512a9c4676","operator":{"type":"boolean","operation":"true","singleValue":true},"leftValue":"={{ $json.data.approved }}","rightValue":""}]}},"typeVersion":2.2},{"id":"0eb866f6-f229-48da-bf53-a19b0439c7a9","name":"Sticky Note","type":"n8n-nodes-base.stickyNote","position":[976,112],"parameters":{"color":7,"width":1280,"height":384,"content":"## Get CRM information\nFetch contact info and associated deals, tickets and compaines."},"typeVersion":1},{"id":"bb2fb3e1-dbcc-468f-9d0b-65e379aad792","name":"Sticky Note1","type":"n8n-nodes-base.stickyNote","position":[496,112],"parameters":{"color":7,"width":464,"height":384,"content":"## Get incoming email"},"typeVersion":1},{"id":"53796f4b-13c6-4af0-ad1b-199ac49ac096","name":"Sticky Note2","type":"n8n-nodes-base.stickyNote","position":[2272,112],"parameters":{"color":7,"width":400,"height":528,"content":"## Write draft response"},"typeVersion":1},{"id":"39b501df-7fc7-470f-8aa4-6dacc05eb255","name":"Sticky Note3","type":"n8n-nodes-base.stickyNote","position":[2688,112],"parameters":{"color":7,"width":704,"height":384,"content":"## Approve and reply"},"typeVersion":1},{"id":"86267d6e-aa6e-4521-a0fb-c823724b7e7d","name":"Workflow Overview","type":"n8n-nodes-base.stickyNote","position":[0,0],"parameters":{"color":5,"width":468,"height":624,"content":"## AI email reply with HubSpot context + Slack approval\n\n### How it works\n1. A new Gmail message arrives.\n2. Look up the sender in HubSpot and fetch related deals, companies, and tickets.\n3. Draft a reply with Gemini.\n4. Post the draft to Slack for approval.\n5. If approved, send a reply\n\n### Setup\n1. **Gmail:** Use the same account for the trigger and the send nodes.\n2. **HubSpot:** Connect all the HubSpot nodes.\n3. **Slack:** Connect Slack and choose where to send the draft for approval.\n4. **Gemini:** Add your [Google AI Studio](https://aistudio.google.com/) API key\n5. **Filter:** Tweak or remove the sender rule before going live.\n\n### Customize\n1. **Prompt:** Adjust tone, length, and how much CRM detail to include.\n2. **Fields:** Pick which deal/company/ticket properties to pull.\n3. **Approval:** Skip Slack to auto-send, or add extra reviewers if needed."},"typeVersion":1},{"id":"f7cb3860-8006-401b-9c4a-2fbb3ca0aa68","name":"Sticky Note6","type":"n8n-nodes-base.stickyNote","position":[3184,432],"parameters":{"color":7,"width":376,"height":232,"content":"### 💡 Customizing this workflow\n\n* Be sure to update your name in the Agent prompt, so it get added to the email signature\n* Add a HubSpot form as the trigger and send customers a personalized followup email\n* Instead of replying to the message, create a draft in your Gmail inbox instead. That way, you'll be able to edit the message before sending."},"typeVersion":1}],"pinData":{"Watch Gmail (New Inbound)":[{"To":"\"miha.ambroz@n8n.io\" <miha.ambroz@n8n.io>","id":"199823d41f5aa56f","From":"Miha Ambroz <miha.ambroz@pm.me>","labels":[{"id":"INBOX","name":"INBOX"},{"id":"IMPORTANT","name":"IMPORTANT"},{"id":"CATEGORY_PERSONAL","name":"CATEGORY_PERSONAL"},{"id":"UNREAD","name":"UNREAD"}],"Subject":"Hey","payload":{"mimeType":"text/plain"},"snippet":"I forgot what I last ordered. Can you help me? Sent from Proton Mail Android","threadId":"199823d41f5aa56f","historyId":"585196","internalDate":"1758826671000","sizeEstimate":4059}]},"connections":{"If Approved?":{"main":[[{"node":"Reply to a message","type":"main","index":0}]]},"Set Record Types":{"main":[[{"node":"List Contact Associations","type":"main","index":0}]]},"Batch Read Objects":{"main":[[{"node":"Normalize CRM Context for LLM","type":"main","index":0}]]},"Find Contact by Email":{"main":[[{"node":"Set Record Types","type":"main","index":0}]]},"Draft Reply (AI Agent)":{"main":[[{"node":"Wait for Response - Approve Auto-Reply","type":"main","index":0}]]},"Filter: Allowed Sender":{"main":[[{"node":"Find Contact by Email","type":"main","index":0}]]},"Google Gemini Chat Model":{"ai_languageModel":[[{"node":"Draft Reply (AI Agent)","type":"ai_languageModel","index":0}]]},"Build Batch Read Requests":{"main":[[{"node":"Batch Read Objects","type":"main","index":0}]]},"List Contact Associations":{"main":[[{"node":"Build Batch Read Requests","type":"main","index":0}]]},"Watch Gmail (New Inbound)":{"main":[[{"node":"Filter: Allowed Sender","type":"main","index":0}]]},"Normalize CRM Context for LLM":{"main":[[{"node":"Draft Reply (AI Agent)","type":"main","index":0}]]},"Wait for Response - Approve Auto-Reply":{"main":[[{"node":"If Approved?","type":"main","index":0}]]}}}