AutomationFlowsAI & RAG › Onsite Photos to Jobs (sms Agent)

Onsite Photos to Jobs (sms Agent)

Onsite Photos to Jobs (SMS Agent). Uses dataTable, twilio, httpRequest, airtable. Webhook trigger; 62 nodes.

Webhook trigger★★★★★ complexity62 nodesData TableTwilioHTTP RequestAirtable
AI & RAG Trigger: Webhook Nodes: 62 Complexity: ★★★★★ Added:

This workflow follows the Airtable → HTTP Request recipe pattern — see all workflows that pair these two integrations.

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "name": "Onsite Photos to Jobs (SMS Agent)",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "onsite-intake",
        "options": {
          "noResponseBody": true
        }
      },
      "id": "ad31b076-19b3-4d58-901a-6dc6a70630cb",
      "name": "SMS In",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2.1,
      "position": [
        0,
        0
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "cfg-vlm-url",
              "name": "vlmUrl",
              "value": "https://YOUR-VISION-ENDPOINT.example.com",
              "type": "string"
            },
            {
              "id": "cfg-vlm-model",
              "name": "vlmModel",
              "value": "qwen2.5-vl-7b",
              "type": "string"
            },
            {
              "id": "cfg-brain-url",
              "name": "brainUrl",
              "value": "https://api.openai.com/v1/chat/completions",
              "type": "string"
            },
            {
              "id": "cfg-brain-model",
              "name": "brainModel",
              "value": "gpt-4o-mini",
              "type": "string"
            },
            {
              "id": "cfg-twilio-from",
              "name": "twilioFrom",
              "value": "SET_TWILIO_FROM_NUMBER",
              "type": "string"
            },
            {
              "id": "cfg-max-questions",
              "name": "maxQuestions",
              "value": 4,
              "type": "number"
            },
            {
              "id": "cfg-vl-prompt",
              "name": "vlSystemPrompt",
              "value": "You are a facilities-maintenance field assessor. You are looking at ONE photo a technician just took on site. Describe what it shows and identify the maintenance problem, if any.\n\nReturn ONLY valid JSON, no markdown fence:\n{\"visible\":\"one line: what the photo shows\",\"issue\":\"the maintenance problem visible, or empty string if none\",\"trade\":\"one of: FM/ Reactive, HVAC, Plumbing, Electrical, Roofing, Painting / Drywall, Doors/ Access Controls, Kitchen Equipment, Hood Cleaning, Elevators, Generator, Remediation, Pool, Property Exterior, Other\",\"priority\":\"P1 - Emergency | P2 - Urgent | P3 - Routine | P4 - Low\",\"job_type\":\"Estimate or Work Order\",\"scope_note\":\"1-2 sentences describing the problem precisely enough for a subcontractor to act on\",\"equipment\":\"nameplate/model/serial text if legible, else empty string\",\"confidence\":\"high | medium | low\",\"question\":\"one short question for the technician if you cannot tell what the problem is or which unit this is, else empty string\"}\n\nRULES: Describe ONLY what you can actually see. Never invent a make, model, or cause. P1 is ONLY for life-safety or active major damage (flood, fire, gas, smoke, no power, entrapment, sewage). A photo with no visible problem is a valid answer: issue empty, confidence high. If the photo is too dark or blurry to judge, set confidence low and ask a question.",
              "type": "string"
            },
            {
              "id": "cfg-group-prompt",
              "name": "groupSystemPrompt",
              "value": "You are the operations coordinator for a facilities-maintenance company. A field technician photographed problems on site. You have a per-photo assessment from a vision model, the technician own notes, and the technician answers to follow-up questions. The technician answers OVERRIDE the vision model when they conflict.\n\nStep 1 - merge: photos showing the SAME physical unit or problem from different angles are ONE issue. Photos of different units are separate issues even if similar (three broken HVAC units = three issues).\nStep 2 - group into jobs: ONE job per trade. All plumbing issues form one Plumbing job with one subtask per issue.\n\nReturn ONLY valid JSON:\n{\"facility_name\":\"best match from the facility list, or empty string\",\"jobs\":[{\"trade\":\"one of the allowed trades exactly\",\"priority\":\"worst priority among its issues: P1 - Emergency | P2 - Urgent | P3 - Routine | P4 - Low\",\"job_type\":\"Estimate if it clearly needs a quote before work, else Work Order\",\"vendor_noun\":\"who to dispatch, e.g. plumber, electrician, HVAC vendor, refrigeration tech, roofer, handyman\",\"scope\":\"one paragraph a subcontractor could bid from, covering every subtask, describing only what is evidenced\",\"subtasks\":[{\"title\":\"short label, e.g. Walk-in cooler 1 - compressor not running\",\"details\":\"1-3 sentences on this specific issue\",\"photo_numbers\":[1,2],\"equipment\":\"nameplate text if known, else empty string\"}]}]}\n\nRULES: Only what the evidence supports - never invent. Every photo with a real issue appears in exactly one subtask. Photos with no issue are omitted. If a photo was unreadable but the technician answer explained it, use the answer. If it is still unknown, add one subtask titled Unidentified issue - review photo under an FM/ Reactive job. No prose, no thinking tags - JSON only.",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "db6230df-5068-4df7-88e1-dd312b55c66b",
      "name": "Onsite Config",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        224,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "const src = $(\"SMS In\").first().json;\nlet b = src.body || src || {};\nif (typeof b === \"string\") { try { b = JSON.parse(b); } catch (e) { b = {}; } }\nconst phone = String(b.From || b.from || \"\").trim();\nconst text = String(b.Body || b.body || \"\").trim();\nconst sid = String(b.MessageSid || b.SmsMessageSid || \"\").trim();\nconst n = parseInt(b.NumMedia || b.numMedia || 0, 10) || 0;\nif (!phone) { return []; }\nconst media = [];\nfor (let i = 0; i < n; i++) {\n  const u = String(b[\"MediaUrl\" + i] || \"\").trim();\n  if (u) media.push({ url: u, contentType: String(b[\"MediaContentType\" + i] || \"image/jpeg\") });\n}\nreturn [{ json: { phone: phone, text: text, media: media, messageSid: sid, receivedAt: new Date().toISOString() } }];"
      },
      "id": "5519bfca-6536-4f85-a7a7-f19f136b947c",
      "name": "Normalize Inbound",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        448,
        0
      ]
    },
    {
      "parameters": {
        "operation": "get",
        "dataTableId": {
          "__rl": true,
          "mode": "name",
          "value": "onsite_sessions"
        },
        "filters": {
          "conditions": [
            {
              "keyName": "phone",
              "keyValue": "={{ $json.phone }}"
            }
          ]
        },
        "returnAll": true
      },
      "id": "78d122a0-1d5c-4665-89d3-7d29dbda370a",
      "name": "Load Session",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        672,
        0
      ],
      "executeOnce": true,
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "jsCode": "const inbound = $(\"Normalize Inbound\").first().json;\nconst rows = $input.all().map(function (r) { return r.json; }).filter(function (r) { return r && r.phone; });\nconst s = rows.length ? rows[0] : null;\nreturn [{ json: {\n  phone: inbound.phone,\n  text: inbound.text,\n  media: inbound.media,\n  messageSid: inbound.messageSid,\n  hasSession: !!s,\n  state: s ? s.state : \"none\",\n  session: s || {}\n} }];"
      },
      "id": "efdef13d-b381-4630-8637-55227c35384a",
      "name": "Merge Context",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        0
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 1
          },
          "conditions": [
            {
              "leftValue": "={{ $json.media.length }}",
              "operator": {
                "type": "number",
                "operation": "gt"
              },
              "rightValue": 0
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "8d4487b9-41f7-4c79-a5f2-be31b05e5656",
      "name": "Has Photos",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1104,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "const ctx = $(\"Merge Context\").first().json;\nconst now = new Date().toISOString();\nreturn ctx.media.map(function (m) { return { json: { phone: ctx.phone, media_url: m.url, content_type: m.contentType, note: ctx.text || \"\", message_sid: ctx.messageSid, received_at: now, status: \"pending\" } }; });"
      },
      "id": "f3fb21d7-bbaf-4576-aa6a-c29618b0355d",
      "name": "Fan Out Photos",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1328,
        -256
      ]
    },
    {
      "parameters": {
        "dataTableId": {
          "__rl": true,
          "mode": "name",
          "value": "onsite_media"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "phone": "={{ $json.phone }}",
            "media_url": "={{ $json.media_url }}",
            "content_type": "={{ $json.content_type }}",
            "note": "={{ $json.note }}",
            "message_sid": "={{ $json.message_sid }}",
            "received_at": "={{ $json.received_at }}",
            "status": "={{ $json.status }}"
          },
          "schema": [
            {
              "id": "phone",
              "displayName": "phone",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "media_url",
              "displayName": "media_url",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "content_type",
              "displayName": "content_type",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "note",
              "displayName": "note",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "message_sid",
              "displayName": "message_sid",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "received_at",
              "displayName": "received_at",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "status",
              "displayName": "status",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ]
        },
        "options": {}
      },
      "id": "8ebfdaee-746e-491d-a6f8-978f2e72afe8",
      "name": "Buffer Photos",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        1552,
        -256
      ]
    },
    {
      "parameters": {
        "jsCode": "const ctx = $(\"Merge Context\").first().json;\nlet reply = \"\";\nlet init = false;\nif (!ctx.hasSession) {\n  init = true;\n  reply = \"Got your photo(s). Keep sending - I hold everything in one batch. Text DONE when you are finished and I will write up the jobs.\";\n} else if (ctx.state === \"clarifying\" || ctx.state === \"awaiting_confirm\" || ctx.state === \"grouping\") {\n  reply = \"Saved these for the next batch - let us finish the current one first.\";\n}\nreturn [{ json: { init: init, smsTo: ctx.phone, smsBody: reply } }];"
      },
      "id": "b8d4b2fd-07f8-4b04-8274-307c91b9f33d",
      "name": "Photo Reply Prep",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1760,
        -256
      ],
      "executeOnce": true
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 1
          },
          "conditions": [
            {
              "leftValue": "={{ $json.init }}",
              "operator": {
                "type": "boolean",
                "operation": "true"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "d3afbdca-7968-4de7-895c-fce8d0dcc0fa",
      "name": "New Session",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1984,
        -256
      ]
    },
    {
      "parameters": {
        "dataTableId": {
          "__rl": true,
          "mode": "name",
          "value": "onsite_sessions"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "phone": "={{ $(\"Merge Context\").first().json.phone }}",
            "state": "collecting",
            "facility_id": "",
            "facility_name": "",
            "note": "={{ $(\"Merge Context\").first().json.text || \"\" }}",
            "photos": "[]",
            "questions": "{\"asked\":[],\"leftover\":[]}",
            "q_index": 0,
            "jobs": "[]",
            "summary": "",
            "updated_at": "={{ $now.toISO() }}"
          },
          "schema": [
            {
              "id": "phone",
              "displayName": "phone",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "state",
              "displayName": "state",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "facility_id",
              "displayName": "facility_id",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "facility_name",
              "displayName": "facility_name",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "note",
              "displayName": "note",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "photos",
              "displayName": "photos",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "questions",
              "displayName": "questions",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "q_index",
              "displayName": "q_index",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "number",
              "canBeUsedToMatch": true
            },
            {
              "id": "jobs",
              "displayName": "jobs",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "summary",
              "displayName": "summary",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "updated_at",
              "displayName": "updated_at",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ]
        },
        "options": {}
      },
      "id": "40d3f53d-e15f-42fb-a56b-1331b2edeebe",
      "name": "Init Session",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        2208,
        -384
      ],
      "executeOnce": true
    },
    {
      "parameters": {
        "jsCode": "const p = $(\"Photo Reply Prep\").first().json;\nreturn [{ json: { smsTo: p.smsTo, smsBody: p.smsBody } }];"
      },
      "id": "1662211e-1481-4f9e-94b7-8cdb08f98b1f",
      "name": "Ack Message",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2432,
        -384
      ],
      "executeOnce": true
    },
    {
      "parameters": {
        "from": "={{ $(\"Onsite Config\").first().json.twilioFrom }}",
        "to": "={{ $json.smsTo }}",
        "message": "={{ $json.smsBody }}",
        "options": {}
      },
      "id": "04415095-9209-4692-877a-d49b3cc99d06",
      "name": "Send SMS",
      "type": "n8n-nodes-base.twilio",
      "typeVersion": 1,
      "position": [
        6704,
        912
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "const p = $(\"Photo Reply Prep\").first().json;\nif (!p.smsBody) { return []; }\nreturn [{ json: { smsTo: p.smsTo, smsBody: p.smsBody } }];"
      },
      "id": "aeafdd3d-b5e4-4aa0-8eb9-409f8c69ad32",
      "name": "Reply If Needed",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2208,
        -144
      ],
      "executeOnce": true
    },
    {
      "parameters": {
        "jsCode": "const cfg = $(\"Onsite Config\").first().json;\nconst ctx = $(\"Merge Context\").first().json;\nconst state = ctx.hasSession ? ctx.state : \"none\";\nconst TRADES = \"FM/ Reactive, HVAC, Plumbing, Electrical, Roofing, Painting / Drywall, Doors/ Access Controls, Kitchen Equipment, Hood Cleaning, Elevators, Generator, Remediation, Pool, Property Exterior, Other\";\nconst sys = \"You are the SMS brain of a facilities-maintenance field-capture line. A field technician texts photos of on-site problems; the system turns them into maintenance jobs grouped by trade. You read the technician latest text plus the conversation state and return ONLY valid JSON, no markdown, no prose. Keep any reply under 300 characters, plain text, no emoji. Never copy instruction or placeholder text into JSON values - values must come from the technician message.\";\nlet user = \"\";\nif (state === \"clarifying\") {\n  let q = \"(unknown)\";\n  try {\n    const qwrap = JSON.parse(ctx.session.questions || \"{}\");\n    const qs = qwrap.asked || [];\n    const qi = parseInt(ctx.session.q_index, 10) || 0;\n    if (qs[qi]) q = qs[qi].q;\n  } catch (e) {}\n  user = \"State: CLARIFYING. We asked the technician this question about their photos: \\\"\" + q + \"\\\". The technician replied: \\\"\" + ctx.text + \"\\\".\\nIf that reply answers the question, return {\\\"intent\\\":\\\"ANSWER\\\",\\\"answer_text\\\":\\\"...\\\"} where answer_text is the technician reply itself, lightly cleaned (fix casing, drop filler). If the technician says skip or does not know, use the word unknown as answer_text. Do NOT write anything else as answer_text.\\nOnly if the reply is clearly not an answer at all (they want to start over, add photos, or ask something unrelated) return {\\\"intent\\\":\\\"OTHER\\\",\\\"reply\\\":\\\"short SMS reply\\\"}\";\n} else if (state === \"awaiting_confirm\") {\n  let jobsShort = \"[]\";\n  try {\n    const jobs = JSON.parse(ctx.session.jobs || \"[]\");\n    jobsShort = JSON.stringify(jobs.map(function (j, i) { return { job: i + 1, wo: j.wo, trade: j.trade, priority: j.priority, job_type: j.jobType, cancelled: !!j.cancelled, scope_start: String(j.scope || \"\").slice(0, 140) }; }));\n  } catch (e) {}\n  user = \"State: AWAITING CONFIRMATION. The technician was sent this summary of the jobs created from their photos:\\n\\\"\\\"\\\"\" + (ctx.session.summary || \"\") + \"\\\"\\\"\\\"\\nThe jobs as JSON (1-based order): \" + jobsShort + \"\\nThe technician replied: \\\"\" + ctx.text + \"\\\".\\nAllowed trades: \" + TRADES + \". Allowed priorities: P1 - Emergency, P2 - Urgent, P3 - Routine, P4 - Low.\\nReturn JSON, exactly one of:\\n{\\\"intent\\\":\\\"CONFIRM\\\"} if they approve as-is (yes, confirm, looks good, correct).\\n{\\\"intent\\\":\\\"EDIT\\\",\\\"ops\\\":[{\\\"job\\\":1,\\\"set\\\":{\\\"trade\\\":\\\"...\\\",\\\"priority\\\":\\\"...\\\",\\\"job_type\\\":\\\"Estimate or Work Order\\\",\\\"scope\\\":\\\"replacement scope text\\\"},\\\"cancel\\\":false}],\\\"reply\\\":\\\"one short line saying what you changed\\\"} for change requests - include in set ONLY keys they asked to change; use cancel true to remove a job.\\n{\\\"intent\\\":\\\"OTHER\\\",\\\"reply\\\":\\\"short reply; if unclear ask them to reply YES or describe the change\\\"}\";\n} else {\n  const preface = state === \"none\" ? \"No photos have arrived yet this session.\" : \"The technician has been texting in photos and has NOT yet said they are finished.\";\n  user = \"State: COLLECTING. \" + preface + \" The technician latest text: \\\"\" + ctx.text + \"\\\".\\nClassify the intent:\\n- DONE: they are finished sending photos and want the jobs written up (done, thats it, all set, go ahead, finished, send it).\\n- NOTE: they are giving context about the photos or the site (facility or community name, building, unit numbers, what something is). Extract it.\\n- STATUS: they ask where things stand.\\n- OTHER: anything else.\\nReturn JSON: {\\\"intent\\\":\\\"DONE or NOTE or STATUS or OTHER\\\",\\\"note\\\":\\\"the context to save if NOTE else empty\\\",\\\"reply\\\":\\\"for STATUS or OTHER only: a short SMS reply that reminds them to text DONE when finished, else empty\\\"}\";\n}\nreturn [{ json: { brainBody: { model: cfg.brainModel, temperature: 0, max_tokens: 3000, messages: [{ role: \"system\", content: sys }, { role: \"user\", content: user }] } } }];"
      },
      "id": "992fa30e-55b4-4425-a36c-200bada3ca00",
      "name": "Build Brain Prompt",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1328,
        272
      ],
      "executeOnce": true
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $(\"Onsite Config\").first().json.brainUrl }}",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "openAiApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.brainBody) }}",
        "options": {
          "response": {
            "response": {
              "neverError": true
            }
          },
          "timeout": 120000
        }
      },
      "id": "34a9b014-9957-49cb-8bbd-abdb6831d789",
      "name": "Brain Intent",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.4,
      "position": [
        1552,
        272
      ]
    },
    {
      "parameters": {
        "jsCode": "const ctx = $(\"Merge Context\").first().json;\nconst state = ctx.hasSession ? ctx.state : \"none\";\nconst resp = $input.first().json || {};\nfunction cleanJson(raw) {\n  raw = String(raw || \"\").replace(/<think>[\\s\\S]*?<\\/think>/g, \"\").trim();\n  if (raw.indexOf(\"```\") === 0) { const parts = raw.split(\"```\"); raw = parts.length > 1 ? parts[1] : raw; if (raw.indexOf(\"json\") === 0) raw = raw.slice(4); }\n  const s = raw.indexOf(\"{\"); const e = raw.lastIndexOf(\"}\");\n  if (s !== -1 && e > s) raw = raw.slice(s, e + 1);\n  return raw;\n}\nlet parsed = null;\ntry {\n  parsed = JSON.parse(cleanJson(resp.choices[0].message.content));\n} catch (e) { parsed = null; }\nif (!parsed || !parsed.intent) {\n  const t = String(ctx.text || \"\").trim().toLowerCase();\n  const saysDone = /(^|\\b)done\\b/.test(t) && !/\\bnot\\s+done\\b/.test(t);\n  const DONE_RE = /^(done|all done|thats it|that is it|finished|finish|all set|send it|go|go ahead|submit|process it|process|thats everything|that is everything)[.! ]*$/;\n  const YES_RE = /^(y|yes|yep|yeah|ok|okay|confirm|confirmed|approve|approved|looks good|good|correct|all good)[.! ]*$/;\n  if (state === \"clarifying\") {\n    parsed = { intent: \"ANSWER\", answer_text: ctx.text };\n  } else if (state === \"awaiting_confirm\" && YES_RE.test(t)) {\n    parsed = { intent: \"CONFIRM\" };\n  } else if ((state === \"collecting\" || state === \"none\") && (saysDone || DONE_RE.test(t))) {\n    parsed = { intent: \"DONE\" };\n  } else {\n    parsed = { intent: \"OTHER\", reply: \"The AI brain is offline so I am in basic mode. Text DONE to process your photos, or YES to confirm jobs - or try again in a minute.\" };\n  }\n}\nlet intent = String(parsed.intent || \"OTHER\").toUpperCase();\nconst allowed = { none: [\"DONE\", \"STATUS\", \"NOTE\", \"OTHER\"], collecting: [\"DONE\", \"STATUS\", \"NOTE\", \"OTHER\"], clarifying: [\"ANSWER\", \"OTHER\"], grouping: [\"OTHER\"], awaiting_confirm: [\"CONFIRM\", \"EDIT\", \"OTHER\"] };\nconst ok = allowed[state] || [\"OTHER\"];\nif (ok.indexOf(intent) === -1) intent = \"OTHER\";\nif (state === \"grouping\") { intent = \"OTHER\"; parsed.reply = \"Still writing up your last batch - give me a minute.\"; }\nif (intent === \"STATUS\") { intent = \"OTHER\"; if (!parsed.reply) parsed.reply = \"I am holding your photos in one batch. Text DONE when you are finished and I will write up the jobs.\"; }\nlet answerText = String(parsed.answer_text || \"\");\nif (intent === \"ANSWER\") {\n  const bad = !answerText.trim() || /their answer cleaned up|answer_text|placeholder|technician reply itself/i.test(answerText);\n  if (bad) answerText = ctx.text;\n}\nreturn [{ json: {\n  intent: intent,\n  reply: String(parsed.reply || \"\"),\n  note: String(parsed.note || \"\"),\n  answer_text: answerText,\n  ops: Array.isArray(parsed.ops) ? parsed.ops : [],\n  phone: ctx.phone,\n  text: ctx.text,\n  state: state\n} }];"
      },
      "id": "4d1f3051-65e1-4d7e-b9de-17b46cde9f3a",
      "name": "Parse Intent",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1760,
        272
      ],
      "executeOnce": true
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 1
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.intent }}",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "rightValue": "DONE"
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "DONE"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 1
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.intent }}",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "rightValue": "ANSWER"
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "ANSWER"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 1
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.intent }}",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "rightValue": "CONFIRM"
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "CONFIRM"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 1
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.intent }}",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "rightValue": "EDIT"
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "EDIT"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 1
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.intent }}",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "rightValue": "NOTE"
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "NOTE"
            }
          ]
        },
        "options": {
          "fallbackOutput": "extra",
          "renameFallbackOutput": "OTHER"
        }
      },
      "id": "105af2af-9056-47c6-97fa-abfa5edf2c0e",
      "name": "Route Intent",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        1984,
        272
      ]
    },
    {
      "parameters": {
        "operation": "get",
        "dataTableId": {
          "__rl": true,
          "mode": "name",
          "value": "onsite_media"
        },
        "matchType": "allConditions",
        "filters": {
          "conditions": [
            {
              "keyName": "phone",
              "keyValue": "={{ $json.phone }}"
            },
            {
              "keyName": "status",
              "keyValue": "pending"
            }
          ]
        },
        "returnAll": true,
        "orderBy": true,
        "orderByColumn": "id",
        "orderByDirection": "ASC"
      },
      "id": "0d2ea2f9-125b-4dd8-8f6d-bb29c66d93bb",
      "name": "Load Pending Photos",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        2208,
        560
      ],
      "executeOnce": true,
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 1
          },
          "conditions": [
            {
              "leftValue": "={{ $json.media_url || \"\" }}",
              "operator": {
                "type": "string",
                "operation": "notEmpty"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "e90844b6-6348-4637-8f97-81c340828388",
      "name": "Any Photos",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2432,
        560
      ]
    },
    {
      "parameters": {
        "operation": "update",
        "dataTableId": {
          "__rl": true,
          "mode": "name",
          "value": "onsite_media"
        },
        "matchType": "allConditions",
        "filters": {
          "conditions": [
            {
              "keyName": "phone",
              "keyValue": "={{ $(\"Merge Context\").first().json.phone }}"
            },
            {
              "keyName": "status",
              "keyValue": "pending"
            }
          ]
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "status": "claimed"
          },
          "schema": [
            {
              "id": "status",
              "displayName": "status",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ]
        },
        "options": {}
      },
      "id": "ebf6a7fc-38c0-4ad2-a3bc-dd1d5f4dab90",
      "name": "Mark Photos Claimed",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        2640,
        704
      ],
      "executeOnce": true
    },
    {
      "parameters": {
        "operation": "search",
        "base": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_BASE_ID",
          "cachedResultName": "Your Maintenance Base"
        },
        "table": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_FACILITIES_TABLE_ID",
          "cachedResultName": "Facilities"
        },
        "options": {}
      },
      "id": "c1dc0c3a-b842-4fc0-ba2f-2339bf28d044",
      "name": "Get Facilities",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 2.2,
      "position": [
        2864,
        704
      ],
      "executeOnce": true
    },
    {
      "parameters": {
        "jsCode": "const cfg = $(\"Onsite Config\").first().json;\nconst ctx = $(\"Merge Context\").first().json;\nconst rows = $(\"Load Pending Photos\").all().map(function (r) { return r.json; }).filter(function (r) { return r && r.media_url; });\nrows.sort(function (a, b) { return (a.id || 0) - (b.id || 0); });\nconst notes = [];\nif (ctx.session && ctx.session.note) notes.push(String(ctx.session.note));\nfor (const r of rows) { const n = String(r.note || \"\").trim(); if (n && notes.indexOf(n) === -1) notes.push(n); }\nconst noteAll = notes.join(\" | \");\nreturn rows.map(function (r, i) {\n  return { json: {\n    photoNumber: i + 1,\n    media_url: r.media_url,\n    rowId: r.id,\n    noteAll: noteAll,\n    vlBody: {\n      model: cfg.vlmModel,\n      temperature: 0.1,\n      max_tokens: 500,\n      messages: [\n        { role: \"system\", content: cfg.vlSystemPrompt },\n        { role: \"user\", content: [\n          { type: \"text\", text: \"Photo \" + (i + 1) + \" from the technician.\" + (noteAll ? \" Technician notes so far: \" + noteAll : \"\") },\n          { type: \"image_url\", image_url: { url: r.media_url } },\n          { type: \"text\", text: \"Return the JSON now.\" }\n        ] }\n      ]\n    }\n  } };\n});"
      },
      "id": "51de7298-b958-4d78-92b6-897dc9255d98",
      "name": "Prep VL Calls",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3088,
        704
      ],
      "executeOnce": true
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $(\"Onsite Config\").first().json.vlmUrl }}/v1/chat/completions",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.vlBody) }}",
        "options": {
          "batching": {
            "batch": {
              "batchSize": 1,
              "batchInterval": 0
            }
          },
          "response": {
            "response": {
              "neverError": true
            }
          },
          "timeout": 120000
        }
      },
      "id": "dcb12e2c-f545-4327-9f15-54c29bdaa56c",
      "name": "Analyze Photo VL",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.4,
      "position": [
        3312,
        704
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "const cfg = $(\"Onsite Config\").first().json;\nconst ctx = $(\"Merge Context\").first().json;\nconst calls = $(\"Prep VL Calls\").all().map(function (r) { return r.json; });\nconst resps = $input.all().map(function (r) { return r.json; });\nconst facs = $(\"Get Facilities\").all().map(function (f) { const raw = f.json.fields || f.json; return { id: f.json.id, name: String(raw.Name || \"\"), acronym: String(raw.Acronym || \"\") }; });\nfunction parseVl(resp) {\n  try {\n    let raw = resp.choices[0].message.content || \"\";\n    raw = String(raw).trim();\n    if (raw.indexOf(\"```\") === 0) { const parts = raw.split(\"```\"); raw = parts.length > 1 ? parts[1] : raw; if (raw.indexOf(\"json\") === 0) raw = raw.slice(4); }\n    const s = raw.indexOf(\"{\"); const e = raw.lastIndexOf(\"}\");\n    if (s !== -1 && e > s) raw = raw.slice(s, e + 1);\n    return JSON.parse(raw);\n  } catch (err) { return null; }\n}\nconst findings = calls.map(function (c, i) {\n  const p = parseVl(resps[i] || {});\n  if (!p) {\n    return { n: c.photoNumber, url: c.media_url, visible: \"\", issue: \"\", trade: \"\", priority: \"\", job_type: \"\", scope_note: \"\", equipment: \"\", confidence: \"low\", question: \"\", error: true, answer: \"\" };\n  }\n  return { n: c.photoNumber, url: c.media_url, visible: String(p.visible || \"\"), issue: String(p.issue || \"\"), trade: String(p.trade || \"\"), priority: String(p.priority || \"\"), job_type: String(p.job_type || \"\"), scope_note: String(p.scope_note || \"\"), equipment: String(p.equipment || \"\"), confidence: String(p.confidence || \"medium\").toLowerCase(), question: String(p.question || \"\"), error: false, answer: \"\" };\n});\nconst noteAll = calls.length ? calls[0].noteAll : \"\";\nconst hay = (noteAll + \" \" + ctx.text).toLowerCase();\nlet fac = null;\nfor (const f of facs) {\n  const nameHit = f.name && hay.indexOf(f.name.toLowerCase()) !== -1;\n  const firstWord = f.name ? f.name.split(\" \")[0].toLowerCase() : \"\";\n  const wordHit = firstWord.length > 3 && hay.indexOf(firstWord) !== -1;\n  let acrHit = false;\n  if (f.acronym) { try { acrHit = new RegExp(\"\\\\b\" + f.acronym.toLowerCase() + \"\\\\b\").test(hay); } catch (e) {} }\n  if (nameHit || wordHit || acrHit) { fac = f; break; }\n}\nconst qs = [];\nif (!fac) qs.push({ type: \"facility\", q: \"Which community are these photos from?\", answer: \"\" });\nfor (const f of findings) {\n  if (f.error) { qs.push({ type: \"photo\", n: f.n, q: \"Photo \" + f.n + \" - I could not read this one. What are we looking at and what is wrong?\", answer: \"\" }); continue; }\n  if (f.question || f.confidence === \"low\" || (!f.issue && f.confidence !== \"high\")) {\n    qs.push({ type: \"photo\", n: f.n, q: \"Photo \" + f.n + (f.visible ? \" (\" + f.visible + \")\" : \"\") + \" - \" + (f.question || \"what exactly is the problem here?\"), answer: \"\" });\n  }\n}\nconst maxQ = parseInt(cfg.maxQuestions, 10) || 4;\nconst asked = qs.slice(0, maxQ);\nconst leftover = qs.slice(maxQ).map(function (q) { return q.q; });\nreturn [{ json: {\n  phone: ctx.phone,\n  needsClarify: asked.length > 0,\n  facilityId: fac ? fac.id : \"\",\n  facilityName: fac ? fac.name : \"\",\n  noteAll: noteAll,\n  photosJson: JSON.stringify(findings),\n  questionsJson: JSON.stringify({ asked: asked, leftover: leftover }),\n  firstQuestion: asked.length ? asked[0].q : \"\",\n  photoCount: findings.length\n} }];"
      },
      "id": "059efdd7-3f01-4291-ac54-f1f6207af28d",
      "name": "Collect Findings",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3520,
        704
      ],
      "executeOnce": false
    },
    {
      "parameters": {
        "operation": "upsert",
        "dataTableId": {
          "__rl": true,
          "mode": "name",
          "value": "onsite_sessions"
        },
        "matchType": "allConditions",
        "filters": {
          "conditions": [
            {
              "keyName": "phone",
              "keyValue": "={{ $json.phone }}"
            }
          ]
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "phone": "={{ $json.phone }}",
            "state": "={{ $json.needsClarify ? \"clarifying\" : \"grouping\" }}",
            "facility_id": "={{ $json.facilityId }}",
            "facility_name": "={{ $json.facilityName }}",
            "note": "={{ $json.noteAll }}",
            "photos": "={{ $json.photosJson }}",
            "questions": "={{ $json.questionsJson }}",
            "q_index": 0,
            "updated_at": "={{ $now.toISO() }}"
          },
          "schema": [
            {
              "id": "phone",
              "displayName": "phone",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "state",
              "displayName": "state",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "facility_id",
              "displayName": "facility_id",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "facility_name",
              "displayName": "facility_name",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "note",
              "displayName": "note",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "photos",
              "displayName": "photos",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "questions",
              "displayName": "questions",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "q_index",
              "displayName": "q_index",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "number",
              "canBeUsedToMatch": true
            },
            {
              "id": "updated_at",
              "displayName": "updated_at",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ]
        },
        "options": {}
      },
      "id": "2992a544-5b3e-47be-8183-58806d939866",
      "name": "Save Analysis State",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        3744,
        704
      ],
      "executeOnce": true,
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 1
          },
          "conditions": [
            {
              "leftValue": "={{ $(\"Collect Findings\").first().json.needsClarify }}",
              "operator": {
                "type": "boolean",
                "operation": "true"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "8b017591-71d9-4acc-a711-d5602bae11f6",
      "name": "Needs Clarify",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        3968,
        704
      ]
    },
    {
      "parameters": {
        "jsCode": "const cf = $(\"Collect Findings\").first().json;\nreturn [{ json: { smsTo: cf.phone, smsBody: \"Read your \" + cf.photoCount + \" photo(s). Quick question before I write the jobs:\\n\" + cf.firstQuestion } }];"
      },
      "id": "c3f806e5-e579-494a-982d-0d9f7529848f",
      "name": "Ask Question",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4192,
        560
      ],
      "executeOnce": true
    },
    {
      "parameters": {
        "operation": "get",
        "dataTableId": {
          "__rl": true,
          "mode": "name",
          "value": "onsite_sessions"
        },
        "filters": {
          "conditions": [
            {
              "keyName": "phone",
              "keyValue": "={{ $(\"Normalize Inbound\").first().json.phone }}"
            }
          ]
        },
        "returnAll": true
      },
      "id": "a31daf90-e202-4a70-8e73-7923cd080406",
      "name": "Load Session Grouping",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        4192,
        1248
      ],
      "executeOnce": true
    },
    {
      "parameters": {
        "jsCode": "const ctx = $(\"Merge Context\").first().json;\nreturn [{ json: { smsTo: ctx.phone, smsBody: \"I do not have any photos from you yet. Snap the problems, text them to this number, then text DONE.\" } }];"
      },
      "id": "805fce6b-e5e8-4bdc-a7c9-32ddc9a18b82",
      "name": "No Photos Reply",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2640,
        432
      ],
      "executeOnce": true
    },
    {
      "parameters": {
        "jsCode": "const ctx = $(\"Merge Context\").first().json;\nconst pi = $(\"Parse Intent\").first().json;\nlet qwrap = { asked: [], leftover: [] };\ntry { qwrap = JSON.parse(ctx.session.questions || \"{}\"); } catch (e) {}\nconst qs = qwrap.asked || [];\nlet findings = [];\ntry { findings = JSON.parse(ctx.session.photos || \"[]\"); } catch (e) {}\nlet qi = parseInt(ctx.session.q_index, 10) || 0;\nconst ans = pi.answer_text || ctx.text;\nlet facilityNameRaw = ctx.session.facility_name || \"\";\nif (qs[qi]) {\n  qs[qi].answer = ans;\n  if (qs[qi].type === \"photo\") {\n    const f = findings.filter(function (x) { return x.n === qs[qi].n; })[0];\n    if (f) f.answer = ans;\n  }\n  if (qs[qi].type === \"facility\") facilityNameRaw = ans;\n}\nqi = qi + 1;\nconst more = qi < qs.length;\nreturn [{ json: {\n  phone: ctx.phone,\n  more: more,\n  nextQuestion: more ? qs[qi].q : \"\",\n  questionsJson: JSON.stringify({ asked: qs, leftover: qwrap.leftover || [] }),\n  photosJson: JSON.stringify(findings),\n  qIndex: qi,\n  facilityNameRaw: facilityNameRaw,\n  state: more ? \"clarifying\" : \"grouping\"\n} }];"
      },
      "id": "b8591e9d-ebdd-4f82-a178-4f17cca924be",
      "name": "Record Answer",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2208,
        992
      ],
      "executeOnce": true
    },
    {
      "parameters": {
        "operation": "upsert",
        "dataTableId": {
          "__rl": true,
          "mode": "name",
          "value": "onsite_sessions"
        },
        "matchType": "allConditions",
        "filters": {
          "conditions": [
            {
              "keyName": "phone",
              "keyValue": "={{ $json.phone }}"
            }
          ]
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "phone": "={{ $json.phone }}",
            "state": "={{ $json.state }}",
            "facility_name": "={{ $json.facilityNameRaw }}",
            "photos": "={{ $json.photosJson }}",
            "questions": "={{ $json.questionsJson }}",
            "q_index": "={{ $json.qIndex }}",
            "updated_at": "={{ $now.toISO() }}"
          },
          "schema": [
            {
              "id": "phone",
              "displayName": "phone",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "state",
              "displayName": "state",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "facility_name",
              "displayName": "facility_name",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "photos",
              "displayName": "photos",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "questions",
              "displayName": "questions",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "q_index",
              "displayName": "q_index",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "number",
              "canBeUsedToMatch": true
            },
            {
              "id": "updated_at",
              "displayName": "updated_at",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ]
        },
        "options": {}
      },
      "id": "3db6a770-ae05-4f04-8dd4-59fe0584d018",
      "name": "Save Answer State",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        2432,
        992
      ],
      "executeOnce": true,
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 1
          },
          "conditions": [
            {
              "leftValue": "={{ $(\"Record Answer\").first().json.more }}",
              "operator": {
                "type": "boolean",
                "operation": "true"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "5a92281a-0dce-46c4-b8be-0e9217ab354c",
      "name": "More Questions",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2640,
        992
      ]
    },
    {
      "parameters": {
        "jsCode": "const ra = $(\"Record Answer\").first().json;\nreturn [{ json: { smsTo: ra.phone, smsBody: \"Got it. Next one:\\n\" + ra.nextQuestion } }];"
      },
      "id": "aa8fb8e9-cac9-47f4-bee2-78b560cf2e13",
      "name": "Ask Next Question",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2864,
        880
      ],
      "executeOnce": true
    },
    {
      "parameters": {
        "jsCode": "const ctx = $(\"Merge Context\").first().json;\nlet jobs = [];\ntry { jobs = JSON.parse(ctx.session.jobs || \"[]\"); } catch (e) {}\nreturn jobs.filter(function (j) { return j.recId && !j.cancelled; }).map(function (j) { return { json: { recId: j.recId } }; });"
      },
      "id": "56aa6cb9-32ab-46da-a90c-87e022f11dc3",
      "name": "Prep Approvals",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2208,
        1568
      ],
      "executeOnce": true
    },
    {
      "parameters": {
        "operation": "update",
        "base": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_BASE_ID",
          "cachedResultName": "Your Maintenance Base"
        },
        "table": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_JOBS_TABLE_ID",
          "cachedResultName": "Jobs"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "matchingColumns": [
            "id"
          ],
          "schema": [],
          "value": {
            "id": "={{ $json.recId }}",
            "Approval Status": "Approved"
          }
        },
        "options": {
          "typecast": true
        }
      },
      "id": "12726e3b-8639-4c7b-969d-16cddd121950",
      "name": "Approve Job",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 2.2,
      "position": [
        2432,
        1568
      ]
    },
    {
      "parameters": {
        "operation": "deleteRows",
        "dataTableId": {
          "__rl": true,
          "mode": "name",
          "value": "onsite_media"
        },
        "matchType": "allConditions",
        "filters": {
          "conditions": [
            {
              "keyName": "phone",
              "keyValue": "={{ $(\"Merge Context\").first().json.phone }}"
            },
            {
              "keyName": "status",
              "keyValue": "claimed"
            }
          ]
        },
        "options": {}
      },
      "id": "f9c707d5-cf81-424d-8979-354377890942",
      "name": "Clear Media Buffer",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        2640,
        1568
      ],
      "executeOnce": true,
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "operation": "deleteRows",
        "dataTableId": {
          "__rl": true,
          "mode": "name",
          "value": "onsite_sessions"
        },
        "matchType": "allConditions",
        "filters": {
          "conditions": [
            {
              "keyName": "phone",
              "keyValue": "={{ $(\"Merge Context\").first().json.phone }}"
            }
          ]
        },
        "options": {}
      },
      "id": "11984a5e-f4d8-4180-9cd8-1c6e085f814a",
      "name": "Clear Session",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        2864,
        1568
      ],
      "executeOnce": true,
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "jsCode": "const ctx = $(\"Merge Context\").first().json;\nlet jobs = [];\ntry { jobs = JSON.parse(ctx.session.jobs || \"[]\"); } catch (e) {}\nconst live = jobs.filter(function (j) { return !j.cancelled; });\nconst wos = live.map(function (j) { return j.wo; }).join(\", \");\nreturn [{ json: { smsTo: ctx.phone, smsBody: live.length + \" job(s) confirmed (\" + wos + \"). Dispatch takes it from here - the office will line up the vendors. Good work out there.\" } }];"
      },
      "id": "7ceaa413-7b28-44ed-a59c-aed1de4666ff",
      "name": "Confirmed Reply",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3088,
        1568
      ],
      "executeOnce": true
    },
    {
      "parameters": {
        "jsCode": "const ctx = $(\"Merge Context\").first().json;\nconst pi = $(\"Parse Intent\").first().json;\nlet jobs = [];\ntry { jobs = JSON.parse(ctx.session.jobs || \"[]\"); } catch (e) {}\nconst CANON = [\"FM/ Reactive\", \"HVAC\", \"Plumbing\", \"Electrical\", \"Roofing\", \"Painting / Drywall\", \"Hood Cleaning\", \"Remediation\", \"ER service\", \"Kitchen Equipment\", \"Doors/ Access Controls\", \"Elevators\", \"Generator\", \"PM Services\", \"Pool\", \"Property Exterior\", \"Building Envelope\", \"Other\"];\nfunction normKey(s) { return String(s || \"\").toLowerCase().replace(/[^a-z0-9]/g, \"\"); }\nconst canonMap = {};\nfor (const t of CANON) canonMap[normKey(t)] = t;\nfunction normTrade(t) {\n  const k = normKey(t);\n  if (canonMap[k]) return canonMap[k];\n  if (k.indexOf(\"hvac\") !== -1) return \"HVAC\";\n  if (k.indexOf(\"plumb\") !== -1) return \"Plumbing\";\n  if (k.indexOf(\"electric\") !== -1) return \"Electrical\";\n  if (k.indexOf(\"kitchen\") !== -1 || k.indexOf(\"cooler\") !== -1 || k.indexOf(\"refriger\") !== -1) return \"Kitchen Equipment\";\n  return \"\";\n}\nfunction normPrio(p) {\n  const k = String(p || \"\").toLowerCase();\n  if (!k) return \"\";\n  if (k.indexOf(\"p1\") !== -1 || k.indexOf(\"emerg\") !== -1) return \"P1 \\u2014 Emergency\";\n  if (k.indexOf(\"p2\") !== -1 || k.indexOf(\"urgent\") !== -1) return \"P2 \\u2014 Urgent\";\n  if (k.indexOf(\"p4\") !== -1 || k.indexOf(\"low\") !== -1) return \"P4 \\u2014 Low\";\n  return \"P3 \\u2014 Routine\";\n}\nconst ops = Array.isArray(pi.ops) ? pi.ops : [];\nconst touched = {};\nops.forEach(function (op) {\n  const idx = (parseInt(op.job, 10) || 0) - 1;\n  const j = jobs[idx];\n  if (!j || !j.recId) return;\n  if (op.cancel === true) { j.cancelled = true; touched[idx] = true; }\n  const set = op.set || {};\n  if (set.trade) { const t = normTrade(set.trade); if (t) { j.trade = t; touched[idx] = true; } }\n  if (set.priority) { const p = normPrio(set.priority); if (p) { j.priority = p; touched[idx] = true; } }\n  if (set.job_type || set.jobType) { const raw = String(set.job_type || set.jobType); j.jobType = raw.toLowerCase().indexOf(\"est\") !== -1 ? \"Estimate\" : \"Work Order\"; touched[idx] = true; }\n  if (set.scope) { j.scope = String(set.scope); touched[idx] = true; }\n});\nconst updates = Object.keys(touched).map(function (k) {\n  const j = jobs[parseInt(k, 10)];\n  return { recId: j.recId, trade: j.trade, priority: j.priority, jobType: j.jobType, scope: j.scope, approval: j.cancelled ? \"Rejected\" : \"\", lifecycle: j.cancelled ? \"Cancelled\" : \"New\" };\n});\nconst live = jobs.filter(function (j) { return !j.cancelled; });\nconst lines = [];\nlines.push(pi.reply || \"Updated.\");\nlines.push(\"\");\nlines.push(\"Current jobs:\");\nlive.forEach(function (j, i) { lines.push((i + 1) + \". \" + j.wo + \" - \" + j.trade + \" (\" + j.subtaskCount + \" subtask\" + (j.subtaskCount === 1 ? \"\" : \"s\") + \", \" + j.priority + \", \" + j.jobType + \")\"); });\nif (!live.length) lines.push(\"(none - everything cancelled)\");\nlines.push(\"\");\nlines.push(\"Reply YES to confirm, or keep editing.\");\nconst summary = lines.join(\"\\n\");\nreturn [{ json: { phone: ctx.phone, smsTo: ctx.phone, smsBody: summary, summary: su
Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

Onsite Photos to Jobs (SMS Agent). Uses dataTable, twilio, httpRequest, airtable. Webhook trigger; 62 nodes.

Source: https://github.com/tom-saida/onsite-photos-to-jobs/blob/main/workflow/onsite-photos-to-jobs.json — original creator credit. Request a take-down →

More AI & RAG workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

AI & RAG

Whatsapp Lead Agent. Uses httpRequest, hunter, @tavily/n8n-nodes-tavily, @mendable/n8n-nodes-firecrawl. Webhook trigger; 35 nodes.

HTTP Request, Hunter, @Tavily/N8N Nodes Tavily +11
AI & RAG

Whatsapp Multi Agent System optimized copy 2.0. Uses airtable, httpRequest, errorTrigger. Webhook trigger; 44 nodes.

Airtable, HTTP Request, Error Trigger
AI & RAG

OpenAI Assistant for Hubspot Chat. Uses httpRequest, airtable. Webhook trigger; 34 nodes.

HTTP Request, Airtable
AI & RAG

Template Overview This n8n workflow provides an intelligent, timezone-aware AI voice calling system for e-commerce businesses to automatically confirm customer orders via phone calls. The system uses

HTTP Request, Airtable, Schedule +1
AI & RAG

AI Lead Qualification & Routing. Uses httpRequest, slack, airtable. Webhook trigger; 9 nodes.

HTTP Request, Slack, Airtable