{
  "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: summary, jobsJson: JSON.stringify(jobs), updates: updates } }];"
      },
      "id": "55043e87-fee0-46a5-98bc-38596b67d2b0",
      "name": "Apply Edits",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2208,
        1888
      ],
      "executeOnce": true
    },
    {
      "parameters": {
        "operation": "update",
        "dataTableId": {
          "__rl": true,
          "mode": "name",
          "value": "onsite_sessions"
        },
        "matchType": "allConditions",
        "filters": {
          "conditions": [
            {
              "keyName": "phone",
              "keyValue": "={{ $json.phone }}"
            }
          ]
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "note": "={{ $(\"Merge Context\").first().json.session.note ? $(\"Merge Context\").first().json.session.note + \" | \" + ($json.note || $json.text) : ($json.note || $json.text) }}",
            "updated_at": "={{ $now.toISO() }}"
          },
          "schema": [
            {
              "id": "note",
              "displayName": "note",
              "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": "23358937-6b46-4b7e-b117-7bc3a6f84955",
      "name": "Save Note",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        2208,
        2144
      ],
      "executeOnce": true,
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "jsCode": "const ctx = $(\"Merge Context\").first().json;\nreturn [{ json: { smsTo: ctx.phone, smsBody: \"Noted. Text DONE when you are finished sending photos.\" } }];"
      },
      "id": "866353a4-420b-456f-b14a-4db84135ab45",
      "name": "Note Reply",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2432,
        2144
      ],
      "executeOnce": true
    },
    {
      "parameters": {
        "jsCode": "const pi = $(\"Parse Intent\").first().json;\nconst ctx = $(\"Merge Context\").first().json;\nconst fallback = \"This line turns your on-site photos into maintenance jobs. Text the photos, then text DONE. You can add notes any time (community, building, unit numbers).\";\nreturn [{ json: { smsTo: ctx.phone, smsBody: pi.reply || fallback } }];"
      },
      "id": "a327f09f-ac60-4648-a329-21c18efcd1e5",
      "name": "Helper Reply",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2208,
        2368
      ],
      "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": "bef6a998-cc71-4504-a24a-c1a0059884ed",
      "name": "Get Facilities Grouping",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 2.2,
      "position": [
        4400,
        1248
      ],
      "executeOnce": true
    },
    {
      "parameters": {
        "jsCode": "const cfg = $(\"Onsite Config\").first().json;\nconst sess = $(\"Load Session Grouping\").first().json;\nconst facs = $(\"Get Facilities Grouping\").all().map(function (f) { const raw = f.json.fields || f.json; return { id: f.json.id, name: String(raw.Name || \"\") }; });\nlet findings = [];\ntry { findings = JSON.parse(sess.photos || \"[]\"); } catch (e) {}\nlet qwrap = { asked: [], leftover: [] };\ntry { qwrap = JSON.parse(sess.questions || \"{}\"); } catch (e) {}\nconst answered = (qwrap.asked || []).filter(function (q) { return q.answer; });\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 lines = findings.map(function (f) {\n  const core = f.error ? \"(vision model could not read this photo)\" : JSON.stringify({ visible: f.visible, issue: f.issue, trade: f.trade, priority: f.priority, job_type: f.job_type, scope_note: f.scope_note, equipment: f.equipment, confidence: f.confidence });\n  return \"Photo \" + f.n + \": \" + core + (f.answer ? \" | TECHNICIAN ANSWER: \" + f.answer : \"\");\n});\nconst user = \"Facility candidates: \" + facs.map(function (f) { return f.name; }).join(\", \") + \".\\nTechnician note: \" + (sess.note || \"(none)\") + (sess.facility_name ? \"\\nTechnician facility answer: \" + sess.facility_name : \"\") + \"\\nAnswered follow-up questions:\\n\" + (answered.length ? answered.map(function (q) { return \"- Q: \" + q.q + \" | A: \" + q.answer; }).join(\"\\n\") : \"(none)\") + \"\\nPer-photo findings:\\n\" + lines.join(\"\\n\") + \"\\nAllowed trades: \" + TRADES + \"\\nReturn the JSON now.\";\nreturn [{ json: { phone: sess.phone, groupBody: { model: cfg.brainModel, temperature: 0.2, max_tokens: 8000, messages: [{ role: \"system\", content: cfg.groupSystemPrompt }, { role: \"user\", content: user }] } } }];"
      },
      "id": "a4479edf-3a3d-42ef-a488-34aa8aeea950",
      "name": "Prep Grouping",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4624,
        1248
      ],
      "executeOnce": false
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $(\"Onsite Config\").first().json.brainUrl }}",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "openAiApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.groupBody) }}",
        "options": {
          "response": {
            "response": {
              "neverError": true
            }
          },
          "timeout": 150000
        }
      },
      "id": "33a9e778-9a85-4545-b382-48c19aba8206",
      "name": "Brain Grouping",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.4,
      "position": [
        4848,
        1248
      ]
    },
    {
      "parameters": {
        "jsCode": "const sess = $(\"Load Session Grouping\").first().json;\nconst facs = $(\"Get Facilities Grouping\").all().map(function (f) { const raw = f.json.fields || f.json; return { id: f.json.id, name: String(raw.Name || \"\"), acronym: String(raw.Acronym || \"\") }; });\nlet findings = [];\ntry { findings = JSON.parse(sess.photos || \"[]\"); } catch (e) {}\nlet qwrap = { asked: [], leftover: [] };\ntry { qwrap = JSON.parse(sess.questions || \"{}\"); } catch (e) {}\nconst resp = $input.first().json || {};\nlet plan = null;\ntry {\n  let raw = String(resp.choices[0].message.content || \"\").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  plan = JSON.parse(raw);\n} catch (e) { plan = null; }\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 || k.indexOf(\"aircond\") !== -1) return \"HVAC\";\n  if (k.indexOf(\"plumb\") !== -1) return \"Plumbing\";\n  if (k.indexOf(\"electric\") !== -1) return \"Electrical\";\n  if (k.indexOf(\"roof\") !== -1) return \"Roofing\";\n  if (k.indexOf(\"paint\") !== -1 || k.indexOf(\"drywall\") !== -1) return \"Painting / Drywall\";\n  if (k.indexOf(\"door\") !== -1 || k.indexOf(\"access\") !== -1) return \"Doors/ Access Controls\";\n  if (k.indexOf(\"kitchen\") !== -1 || k.indexOf(\"cooler\") !== -1 || k.indexOf(\"refriger\") !== -1 || k.indexOf(\"freezer\") !== -1) return \"Kitchen Equipment\";\n  if (k.indexOf(\"hood\") !== -1) return \"Hood Cleaning\";\n  if (k.indexOf(\"elevator\") !== -1) return \"Elevators\";\n  if (k.indexOf(\"generator\") !== -1) return \"Generator\";\n  if (k.indexOf(\"remediation\") !== -1 || k.indexOf(\"mold\") !== -1) return \"Remediation\";\n  if (k.indexOf(\"pool\") !== -1) return \"Pool\";\n  if (k.indexOf(\"exterior\") !== -1 || k.indexOf(\"landscap\") !== -1) return \"Property Exterior\";\n  return \"FM/ Reactive\";\n}\nfunction normPrio(p) {\n  const k = String(p || \"\").toLowerCase();\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 NOUNS = { \"HVAC\": \"HVAC vendor\", \"Plumbing\": \"plumber\", \"Electrical\": \"electrician\", \"Roofing\": \"roofer\", \"Kitchen Equipment\": \"refrigeration/kitchen tech\", \"Painting / Drywall\": \"painter\", \"Doors/ Access Controls\": \"door/access tech\", \"Hood Cleaning\": \"hood cleaning crew\", \"Elevators\": \"elevator tech\", \"Generator\": \"generator tech\", \"Remediation\": \"remediation crew\", \"Pool\": \"pool tech\", \"Property Exterior\": \"exterior crew\", \"FM/ Reactive\": \"handyman\" };\nlet jobs = (plan && Array.isArray(plan.jobs)) ? plan.jobs : [];\nif (!jobs.length) {\n  const withIssues = findings.filter(function (f) { return f.issue || f.answer || f.error; });\n  const subs = (withIssues.length ? withIssues : findings).map(function (f) {\n    return { title: \"Photo \" + f.n + \" - \" + (f.issue || f.answer || \"review needed\").slice(0, 80), details: (f.scope_note || f.answer || \"The automated read failed; review the photo.\"), photo_numbers: [f.n], equipment: f.equipment || \"\" };\n  });\n  jobs = [{ trade: \"FM/ Reactive\", priority: \"P3 - Routine\", job_type: \"Work Order\", vendor_noun: \"handyman\", scope: \"Field photos received from the technician. Automatic grouping was unavailable; the issues below were carried over photo-by-photo and need a human read before dispatch.\", subtasks: subs }];\n}\nlet facName = (plan && plan.facility_name) || sess.facility_name || \"\";\nlet fac = null;\nconst fk = normKey(facName);\nif (fk) {\n  for (const f of facs) {\n    if (normKey(f.name) === fk || normKey(f.name).indexOf(fk) !== -1 || fk.indexOf(normKey(f.name)) !== -1 || (f.acronym && normKey(f.acronym) === fk)) { fac = f; break; }\n  }\n}\nif (!fac && fk) {\n  for (const f of facs) {\n    const fw = normKey(String(f.name).split(\" \")[0]);\n    if (fw && fw.length > 3 && fk.indexOf(fw) !== -1) { fac = f; break; }\n  }\n}\nif (!fac && sess.facility_id) fac = { id: sess.facility_id, name: sess.facility_name || \"\" };\nconst leftover = qwrap.leftover || [];\nconst items = jobs.map(function (j, idx) {\n  const trade = normTrade(j.trade);\n  const priority = normPrio(j.priority);\n  const jobType = String(j.job_type || j.jobType || \"\").toLowerCase().indexOf(\"est\") !== -1 ? \"Estimate\" : \"Work Order\";\n  let subtasks = Array.isArray(j.subtasks) ? j.subtasks : [];\n  if (!subtasks.length) subtasks = [{ title: trade + \" - see scope\", details: String(j.scope || \"\"), photo_numbers: [], equipment: \"\" }];\n  subtasks = subtasks.map(function (s) {\n    const nums = Array.isArray(s.photo_numbers) ? s.photo_numbers : [];\n    const photos = nums.map(function (n) { const f = findings.filter(function (x) { return x.n === parseInt(n, 10); })[0]; return f ? { url: f.url } : null; }).filter(function (x) { return x; });\n    return { title: String(s.title || \"Issue\"), details: String(s.details || \"\"), photo_numbers: nums, equipment: String(s.equipment || \"\"), photos: photos };\n  });\n  const seen = {};\n  const jobPhotos = [];\n  for (const s of subtasks) { for (const p of s.photos) { if (!seen[p.url]) { seen[p.url] = true; jobPhotos.push(p); } } }\n  const scope = String(j.scope || \"\").trim() || \"Field capture - see subtasks.\";\n  const dl = [];\n  dl.push(\"Field capture from \" + sess.phone + (fac && fac.name ? \" at \" + fac.name : \" - FACILITY NOT SET, fix before dispatch\"));\n  dl.push(\"\");\n  dl.push(scope);\n  dl.push(\"\");\n  dl.push(\"Subtasks:\");\n  for (const s of subtasks) { dl.push(\"- \" + s.title + (s.details ? \": \" + s.details : \"\") + (s.equipment ? \" [Equipment: \" + s.equipment + \"]\" : \"\")); }\n  if (leftover.length) { dl.push(\"\"); dl.push(\"Unresolved questions (question limit reached): \" + leftover.join(\" \")); }\n  dl.push(\"\");\n  dl.push(priority + \" | \" + jobType + \" | \" + jobPhotos.length + \" photo(s) | Source: Onsite App SMS intake.\");\n  dl.push(\"Awaiting field-tech confirmation over SMS; the tech can confirm or edit by text. Approval Status is set to Approved on their YES.\");\n  return { json: {\n    jobIndex: idx + 1,\n    trade: trade,\n    priority: priority,\n    jobType: jobType,\n    vendorNoun: String(j.vendor_noun || NOUNS[trade] || (trade + \" vendor\")),\n    scope: scope,\n    subtasks: subtasks,\n    jobPhotos: jobPhotos,\n    facilityId: fac ? fac.id : \"\",\n    facilityName: fac ? fac.name : \"\",\n    draft: dl.join(\"\\n\"),\n    phone: sess.phone\n  } };\n});\nreturn items;"
      },
      "id": "57ef85b0-7da4-4c9f-87e4-d8fc46839d67",
      "name": "Parse Job Plan",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        5072,
        1248
      ],
      "executeOnce": true
    },
    {
      "parameters": {
        "operation": "create",
        "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": [],
          "schema": [],
          "value": {
            "Scope": "={{ $json.scope }}",
            "Source": "Onsite App",
            "Priority": "={{ $json.priority }}",
            "Trade Category": "={{ [$json.trade] }}",
            "Job Type": "={{ $json.jobType }}",
            "Job Status": "={{ [\"New WO/ FM\"] }}",
            "Lifecycle State": "New",
            "Facility": "={{ $json.facilityId ? [$json.facilityId] : [] }}",
            "Requester Name": "={{ \"Field tech \" + $json.phone }}",
            "Requester Contact": "={{ $json.phone }}",
            "Photos": "={{ $json.jobPhotos }}",
            "Action": "Confirm New Job",
            "Draft": "={{ $json.draft }}"
          }
        },
        "options": {
          "typecast": true
        }
      },
      "id": "d196ce17-8150-4d53-856f-31dc522771b2",
      "name": "Create Job",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 2.2,
      "position": [
        5280,
        1248
      ]
    },
    {
      "parameters": {
        "jsCode": "const jobs = $input.all();\nconst plans = $(\"Parse Job Plan\").all();\nconst out = [];\njobs.forEach(function (jr, i) {\n  const p = plans[i] ? plans[i].json : null;\n  if (!p) return;\n  const recId = jr.json.id;\n  (p.subtasks || []).forEach(function (s) {\n    out.push({ json: {\n      title: s.title,\n      details: s.details || \"\",\n      equipment: s.equipment || \"\",\n      photoRefs: (s.photo_numbers || []).map(function (n) { return \"Photo \" + n; }).join(\", \"),\n      photos: s.photos || [],\n      jobRecId: recId\n    } });\n  });\n});\nreturn out;"
      },
      "id": "44334043-1068-4909-970a-100aad2c13af",
      "name": "Fan Out Subtasks",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        5504,
        1248
      ]
    },
    {
      "parameters": {
        "operation": "create",
        "base": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_BASE_ID",
          "cachedResultName": "Your Maintenance Base"
        },
        "table": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_SUBTASKS_TABLE_ID",
          "cachedResultName": "Job Subtasks"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "schema": [],
          "value": {
            "Subtask": "={{ $json.title }}",
            "Job": "={{ [$json.jobRecId] }}",
            "Details": "={{ $json.details }}",
            "Status": "Open",
            "Equipment": "={{ $json.equipment }}",
            "Photos": "={{ $json.photos }}",
            "Photo Refs": "={{ $json.photoRefs }}"
          }
        },
        "options": {
          "typecast": true
        }
      },
      "id": "31850c9d-19d7-4d34-b6c9-bd1037d720a1",
      "name": "Create Subtask",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 2.2,
      "position": [
        5728,
        1248
      ]
    },
    {
      "parameters": {
        "jsCode": "const jobs = $(\"Create Job\").all();\nconst plans = $(\"Parse Job Plan\").all().map(function (r) { return r.json; });\nconst sess = $(\"Load Session Grouping\").first().json;\nlet findings = [];\ntry { findings = JSON.parse(sess.photos || \"[]\"); } catch (e) {}\nconst fac = plans.length ? plans[0].facilityName : \"\";\nconst lines = [];\nlines.push(\"Here is what I got from your \" + findings.length + \" photo(s)\" + (fac ? \" at \" + fac : \"\") + \":\");\nplans.forEach(function (p) {\n  const titles = p.subtasks.map(function (s) { return s.title; }).join(\"; \");\n  lines.push(\"- \" + p.trade + \": \" + p.subtasks.length + \" issue(s) - \" + titles.slice(0, 160));\n});\nlines.push(\"\");\nlines.push(plans.length + \" job(s) created in Airtable:\");\nconst jobRecs = [];\nplans.forEach(function (p, i) {\n  const j = jobs[i] ? jobs[i].json : {};\n  const jf = j.fields || j;\n  const wo = jf[\"Work Order #\"] || jf[\"W.O. #\"] || (\"Job \" + (i + 1));\n  jobRecs.push({ recId: j.id || \"\", wo: wo, trade: p.trade, priority: p.priority, jobType: p.jobType, scope: p.scope, vendorNoun: p.vendorNoun, subtaskCount: p.subtasks.length, cancelled: false });\n  lines.push((i + 1) + \". \" + wo + \" - \" + p.trade + \" (\" + p.subtasks.length + \" subtask\" + (p.subtasks.length === 1 ? \"\" : \"s\") + \", \" + p.priority + \")\");\n});\nlines.push(\"Vendors to dispatch: \" + plans.map(function (p) { return \"1 \" + p.vendorNoun; }).join(\", \") + \".\");\nif (!fac) lines.push(\"Heads up: no facility matched - tell me the community and I will set it.\");\nlines.push(\"\");\nlines.push(\"Reply YES to confirm all, or tell me what to change (like: make job 2 urgent / cancel job 1 / job 3 should be an estimate).\");\nconst smsBody = lines.join(\"\\n\");\nreturn [{ json: { phone: sess.phone, smsTo: sess.phone, smsBody: smsBody, jobsJson: JSON.stringify(jobRecs), summary: smsBody } }];"
      },
      "id": "a1330187-5e6f-45c3-8d5b-179badbb9abd",
      "name": "Compose Summary",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        5952,
        1248
      ],
      "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": "awaiting_confirm",
            "jobs": "={{ $json.jobsJson }}",
            "summary": "={{ $json.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": "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": "7e817328-6908-4f79-8902-094114dfc7b7",
      "name": "Save Awaiting Confirm",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        6160,
        1248
      ],
      "executeOnce": true,
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "jsCode": "const cs = $(\"Compose Summary\").first().json;\nreturn [{ json: { smsTo: cs.smsTo, smsBody: cs.smsBody } }];"
      },
      "id": "0b6b872e-bb27-4f26-b501-be1db11f77b7",
      "name": "Summary Reply",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        6384,
        1248
      ],
      "executeOnce": true
    },
    {
      "parameters": {
        "jsCode": "const ae = $(\"Apply Edits\").first().json;\nreturn (ae.updates || []).map(function (u) { return { json: u }; });"
      },
      "id": "e146fd65-143e-4e44-a98b-f9b4169ca29f",
      "name": "Fan Out Updates",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2432,
        1984
      ],
      "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 }}",
            "Trade Category": "={{ [$json.trade] }}",
            "Priority": "={{ $json.priority }}",
            "Job Type": "={{ $json.jobType }}",
            "Scope": "={{ $json.scope }}",
            "Approval Status": "={{ $json.approval }}",
            "Lifecycle State": "={{ $json.lifecycle }}"
          }
        },
        "options": {
          "typecast": true
        }
      },
      "id": "894bb638-a3aa-4335-a70c-ec18314d37fc",
      "name": "Update Job",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 2.2,
      "position": [
        2640,
        1984
      ]
    },
    {
      "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": "awaiting_confirm",
            "jobs": "={{ $json.jobsJson }}",
            "summary": "={{ $json.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": "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": "942ee08c-5d17-4344-88c6-497dc6099e99",
      "name": "Save Edited Session",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        2432,
        1792
      ],
      "executeOnce": true,
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "jsCode": "const ae = $(\"Apply Edits\").first().json;\nreturn [{ json: { smsTo: ae.smsTo, smsBody: ae.smsBody } }];"
      },
      "id": "8874a23b-8411-420a-b494-a4232be22a04",
      "name": "Edited Reply",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2640,
        1792
      ],
      "executeOnce": true
    },
    {
      "parameters": {
        "content": "## 1. One webhook, no polling\nTwilio posts every inbound SMS/MMS here. Photos are buffered per phone number; texts are routed by conversation state. Nothing runs on a schedule - the workflow only acts when the tech texts.\n\nPoint a Twilio number (\"A message comes in\") at this webhook URL.",
        "height": 200,
        "width": 1400,
        "color": 4
      },
      "id": "998238c7-d8b3-4760-bb3d-128430a9b00b",
      "name": "Sticky Note 5d041e00",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -336,
        256
      ]
    },
    {
      "parameters": {
        "content": "## Config lives in ONE node\nOnsite Config holds every deployment-specific value:\n- vlmUrl: any OpenAI-compatible VISION endpoint (a local Qwen2.5-VL via server/start_vlm.sh, or a cloud vision model)\n- brainUrl + brainModel: any OpenAI-compatible CHAT endpoint for the conversation brain (defaults to OpenAI gpt-4o-mini; a local model via LM Studio works too - see README)\n- twilioFrom: the Twilio number that sends replies (SET THIS)\n- maxQuestions: how many follow-ups the agent may ask per batch\n- both AI prompts, so they can be tuned without touching code\n\nAuth: the vision call uses a Header Auth credential (name: Authorization, value: Bearer <your key>). The brain call uses an OpenAI credential.",
        "height": 200,
        "width": 300,
        "color": 3
      },
      "id": "406f026d-af2b-40ea-99f4-d364aac13230",
      "name": "Sticky Note 74a42e05",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1200,
        496
      ]
    },
    {
      "parameters": {
        "content": "## 2. DONE -> two-model analysis\nWhen the tech texts DONE: each photo goes through the local Qwen2.5-VL individually (reliable for a 7B; one bad photo cannot sink the batch). Photos the model cannot call become follow-up questions - texted to the tech one at a time, answers folded back in. Facility unmatched? That is always the first question.",
        "height": 340,
        "width": 1620,
        "color": 5
      },
      "id": "83ffc9f9-098f-492b-8bbf-508cfea05b03",
      "name": "Sticky Note c37121a4",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2160,
        512
      ]
    },
    {
      "parameters": {
        "content": "## 3. One job per TRADE, subtasks per issue\nThe OpenAI brain merges per-photo findings + tech answers into issues, then groups issues into one job per trade (4 burst pipes = 1 Plumbing job, 4 subtasks). Jobs land in Jobs with photos + Draft; each issue becomes a Job Subtasks row with its own photos. Approval Status stays EMPTY until the tech confirms - so the owner approval mailer does not grab them mid-conversation.",
        "height": 200,
        "width": 1620,
        "color": 6
      },
      "id": "ab4a6a29-f017-467f-a7fc-0089c98160aa",
      "name": "Sticky Note 3fa6cade",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        4576,
        1200
      ]
    },
    {
      "parameters": {
        "content": "## 4. Tech confirms by text\nSummary SMS lists issues, jobs, and vendors to dispatch. YES -> Approval Status = Approved on every job (the dispatch workflow takes over from there), buffers cleared. Edits (\"make job 2 urgent\", \"cancel job 1\") are applied to Airtable and the summary is re-sent. All outbound SMS goes through one Twilio node - it continues on error so state still advances while A2P 10DLC approval is pending.",
        "height": 1180,
        "width": 4800,
        "color": 2
      },
      "id": "0a24c83d-cc54-4d4f-a87b-1803c2e50d10",
      "name": "Sticky Note 07f64ab5",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2160,
        864
      ]
    }
  ],
  "connections": {
    "SMS In": {
      "main": [
        [
          {
            "node": "Onsite Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Onsite Config": {
      "main": [
        [
          {
            "node": "Normalize Inbound",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize Inbound": {
      "main": [
        [
          {
            "node": "Load Session",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load Session": {
      "main": [
        [
          {
            "node": "Merge Context",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge Context": {
      "main": [
        [
          {
            "node": "Has Photos",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has Photos": {
      "main": [
        [
          {
            "node": "Fan Out Photos",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build Brain Prompt",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fan Out Photos": {
      "main": [
        [
          {
            "node": "Buffer Photos",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Buffer Photos": {
      "main": [
        [
          {
            "node": "Photo Reply Prep",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Photo Reply Prep": {
      "main": [
        [
          {
            "node": "New Session",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "New Session": {
      "main": [
        [
          {
            "node": "Init Session",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Reply If Needed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Init Session": {
      "main": [
        [
          {
            "node": "Ack Message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Ack Message": {
      "main": [
        [
          {
            "node": "Send SMS",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reply If Needed": {
      "main": [
        [
          {
            "node": "Send SMS",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Brain Prompt": {
      "main": [
        [
          {
            "node": "Brain Intent",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Brain Intent": {
      "main": [
        [
          {
            "node": "Parse Intent",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Intent": {
      "main": [
        [
          {
            "node": "Route Intent",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route Intent": {
      "main": [
        [
          {
            "node": "Load Pending Photos",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Record Answer",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Prep Approvals",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Apply Edits",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Save Note",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Helper Reply",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load Pending Photos": {
      "main": [
        [
          {
            "node": "Any Photos",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Any Photos": {
      "main": [
        [
          {
            "node": "Mark Photos Claimed",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "No Photos Reply",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mark Photos Claimed": {
      "main": [
        [
          {
            "node": "Get Facilities",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Facilities": {
      "main": [
        [
          {
            "node": "Prep VL Calls",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prep VL Calls": {
      "main": [
        [
          {
            "node": "Analyze Photo VL",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Analyze Photo VL": {
      "main": [
        [
          {
            "node": "Collect Findings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Collect Findings": {
      "main": [
        [
          {
            "node": "Save Analysis State",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save Analysis State": {
      "main": [
        [
          {
            "node": "Needs Clarify",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Needs Clarify": {
      "main": [
        [
          {
            "node": "Ask Question",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Load Session Grouping",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Ask Question": {
      "main": [
        [
          {
            "node": "Send SMS",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load Session Grouping": {
      "main": [
        [
          {
            "node": "Get Facilities Grouping",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "No Photos Reply": {
      "main": [
        [
          {
            "node": "Send SMS",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Record Answer": {
      "main": [
        [
          {
            "node": "Save Answer State",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save Answer State": {
      "main": [
        [
          {
            "node": "More Questions",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "More Questions": {
      "main": [
        [
          {
            "node": "Ask Next Question",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Load Session Grouping",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Ask Next Question": {
      "main": [
        [
          {
            "node": "Send SMS",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prep Approvals": {
      "main": [
        [
          {
            "node": "Approve Job",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Approve Job": {
      "main": [
        [
          {
            "node": "Clear Media Buffer",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Clear Media Buffer": {
      "main": [
        [
          {
            "node": "Clear Session",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Clear Session": {
      "main": [
        [
          {
            "node": "Confirmed Reply",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Confirmed Reply": {
      "main": [
        [
          {
            "node": "Send SMS",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Apply Edits": {
      "main": [
        [
          {
            "node": "Fan Out Updates",
            "type": "main",
            "index": 0
          },
          {
            "node": "Save Edited Session",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save Note": {
      "main": [
        [
          {
            "node": "Note Reply",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Note Reply": {
      "main": [
        [
          {
            "node": "Send SMS",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Helper Reply": {
      "main": [
        [
          {
            "node": "Send SMS",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Facilities Grouping": {
      "main": [
        [
          {
            "node": "Prep Grouping",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prep Grouping": {
      "main": [
        [
          {
            "node": "Brain Grouping",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Brain Grouping": {
      "main": [
        [
          {
            "node": "Parse Job Plan",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Job Plan": {
      "main": [
        [
          {
            "node": "Create Job",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create Job": {
      "main": [
        [
          {
            "node": "Fan Out Subtasks",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fan Out Subtasks": {
      "main": [
        [
          {
            "node": "Create Subtask",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create Subtask": {
      "main": [
        [
          {
            "node": "Compose Summary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compose Summary": {
      "main": [
        [
          {
            "node": "Save Awaiting Confirm",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save Awaiting Confirm": {
      "main": [
        [
          {
            "node": "Summary Reply",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Summary Reply": {
      "main": [
        [
          {
            "node": "Send SMS",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fan Out Updates": {
      "main": [
        [
          {
            "node": "Update Job",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save Edited Session": {
      "main": [
        [
          {
            "node": "Edited Reply",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Edited Reply": {
      "main": [
        [
          {
            "node": "Send SMS",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  }
}