{
  "name": "[Subworkflow.ai] Customer support Triage with Jev",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "hours"
            }
          ]
        }
      },
      "id": "b9347af1-b738-4269-84db-c42baedf9ec3",
      "name": "Schedule Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        -144,
        816
      ]
    },
    {
      "parameters": {
        "operation": "getAll",
        "limit": 3,
        "simple": false,
        "filters": {
          "q": "to:support@example.com"
        },
        "options": {}
      },
      "id": "d8c15a03-b31d-45ef-ad5b-15ba6a4adc88",
      "name": "Get Recent Messages",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        32,
        816
      ],
      "webhookId": "72ecc0de-485d-4b74-af35-237953e1a5e8"
    },
    {
      "parameters": {
        "operation": "removeItemsSeenInPreviousExecutions",
        "dedupeValue": "={{ $json.id }}",
        "options": {}
      },
      "id": "0b2a6611-566d-481a-b594-e6c311f23c27",
      "name": "Mark as Seen",
      "type": "n8n-nodes-base.removeDuplicates",
      "typeVersion": 2,
      "position": [
        192,
        816
      ]
    },
    {
      "parameters": {
        "html": "={{ $json.html }}",
        "options": {}
      },
      "id": "534a38c7-68f1-4ace-ae30-6afe510aa4b9",
      "name": "Markdown",
      "type": "n8n-nodes-base.markdown",
      "typeVersion": 1,
      "position": [
        352,
        816
      ]
    },
    {
      "parameters": {
        "jsCode": "// Build the Jev Decisions request for one inbound support email.\n//\n// All 12 questions go in ONE call. Per TypeSafe's speculative fan-out guidance\n// there is no latency cost for extra questions, so asking every label upfront\n// costs the same as asking one.\nconst MODEL = '~typesafe/jev-latest';\n\n// Pick-one sets -> Choice. Each includes a no-match option, because a Choice\n// with no escape hatch forces a wrong answer on an input that fits nothing.\nconst TAGS = {\n  regression: 'something that used to work has stopped working',\n  'new-bug': 'a defect that appears to be new, not a regression',\n  'config-issue': 'caused by configuration or setup rather than a defect',\n  'user-error': 'the product behaved correctly; the user did something unintended',\n  'third-party': 'caused by an external service or integration',\n  unclear: 'the message does not contain enough to tell',\n};\n\nconst TEAMS = {\n  Platform: 'core infrastructure, APIs, performance, outages',\n  'Identity & Access': 'login, SSO, passwords, permissions, sessions',\n  'Billing Ops': 'invoices, charges, refunds, subscriptions, seats',\n  'Product Support': 'how-to questions, training, general product guidance',\n  Security: 'suspected compromise, vulnerabilities, unauthorised access',\n  unassigned: 'no team is a clear fit from this message alone',\n};\n\n// Several tools may be worth reaching for, so one Noul each rather than a Choice.\nconst TOOLS = {\n  Sentry: 'Would an application stack trace or error report help diagnose this?',\n  'Stripe Dashboard': 'Would checking payment, invoice or subscription records help resolve this?',\n  'Identity Admin': 'Would inspecting the user\\'s account, sessions or permissions help resolve this?',\n};\n\n// One Noul per label - TypeSafe's documented pattern when several may apply.\n// A single multi-select cannot give you a per-label probability to threshold.\nconst LABELS = {\n  Technical:  'Is this a technical fault with the product (bugs, errors, outages)?',\n  Account:    \"Does this concern the customer's account itself (profile, settings, ownership)?\",\n  Access:     'Is the customer unable to log in or reach something they should be able to reach?',\n  Billing:    'Does this concern charges, invoices, refunds or payment?',\n  Product:    'Is this about product capability or behaviour, rather than a fault?',\n  Training:   'Is the customer asking how to do something, rather than reporting a problem?',\n  Feedback:   'Is the customer offering a suggestion or feature request?',\n  Complaints: 'Is the customer expressing dissatisfaction with the service or company, beyond the technical issue itself?',\n  Security:   'Does this involve a security concern such as unauthorised access, vulnerabilities or compromised credentials?',\n  Privacy:    'Does this involve personal data handling, retention, or a privacy request?',\n};\n\nconst out = [];\n\nfor (const item of $input.all()) {\n  const j = item.json;\n  const fromEntry = j.from?.value?.[0] ?? {};\n  const body = String(j.data ?? j.text ?? '').replace(/\\s+\\n/g, '\\n').trim();\n\n  const questions = {};\n  for (const [label, instructions] of Object.entries(LABELS)) {\n    questions['label_' + label] = { type: 'noul', instructions };\n  }\n\n  questions.severity = {\n    type: 'score',\n    instructions: 'How severe is the impact on the customer right now?',\n    criteria: [\n      'no real impact; cosmetic, informational, or a general question',\n      'minor inconvenience with an easy workaround',\n      'meaningful disruption to normal work',\n      'a core workflow is blocked with no workaround',\n      'business-critical; money, data, or security is actively at risk',\n    ],\n  };\n\n  // --- gates: asked first, consumed first -----------------------------------\n  questions.is_actionable_request = {\n    type: 'noul',\n    instructions:\n      'Is this a genuine support request that needs someone to do something? Answer no for auto-replies, out-of-office notices, newsletters, vendor marketing, delivery receipts, and messages that only say thanks or confirm something is resolved.',\n  };\n  questions.is_reply_to_existing_thread = {\n    type: 'noul',\n    instructions:\n      'Does this message read as a continuation of an existing conversation rather than a new report? Look for quoted history, \"as I mentioned\", ticket references, or a reply subject.',\n  };\n\n  // --- safety: checked before any of this text is copied into Linear ---------\n  questions.contains_sensitive_data = {\n    type: 'noul',\n    instructions:\n      'Does this message contain sensitive data that should not be copied into a broadly readable issue tracker? Examples: payment card numbers, national identifiers, home addresses, health information, or personal data about third parties.',\n  };\n  questions.contains_credentials = {\n    type: 'noul',\n    instructions:\n      'Has the sender included a live secret in the message body, such as a password, API key, access token, or private key?',\n  };\n\n  // --- speculative: only read when the matching label fires -----------------\n  // Free to ask alongside everything else; code discards the irrelevant ones.\n  questions.refund_requested = {\n    type: 'noul',\n    instructions: 'Is the sender asking for money back, a credit, or to cancel and be refunded?',\n  };\n  questions.disputed_amount = {\n    type: 'choice',\n    instructions: 'If a disputed or queried monetary amount is stated, roughly how large is it?',\n    criteria: {\n      not_stated: 'no specific amount is mentioned',\n      under_100: 'less than about 100 in the stated currency',\n      '100_to_1000': 'roughly 100 to 1000',\n      over_1000: 'more than about 1000',\n    },\n  };\n  questions.credentials_compromised = {\n    type: 'noul',\n    instructions: 'Does this describe an account or credential that may already be in someone else\\'s hands?',\n  };\n  questions.affects_other_customers = {\n    type: 'noul',\n    instructions: 'Does this describe a problem that plausibly affects customers beyond the sender?',\n  };\n  questions.whole_team_locked_out = {\n    type: 'noul',\n    instructions: 'Is more than one person at the sender\\'s organisation blocked by this, rather than the sender alone?',\n  };\n\n  questions.tag = {\n    type: 'choice',\n    instructions: 'Which single tag best describes the nature of this issue?',\n    criteria: TAGS,\n  };\n\n  questions.team = {\n    type: 'choice',\n    instructions: 'Which single team is best placed to own this issue?',\n    criteria: TEAMS,\n  };\n\n  for (const [tool, instructions] of Object.entries(TOOLS)) {\n    questions['tool_' + tool.replace(/\\s+/g, '_')] = { type: 'noul', instructions };\n  }\n\n  out.push({\n    json: {\n      messageId: j.id ?? null,\n      reporterName: fromEntry.name ?? null,\n      reporterEmail: fromEntry.address ?? null,\n      subject: j.subject ?? '(no subject)',\n      body,\n      startedAt: Date.now(),\n      body_request: {\n        model: MODEL,\n        state: {\n          from: `${fromEntry.name ?? ''} <${fromEntry.address ?? ''}>`.trim(),\n          subject: j.subject ?? '',\n          message: body,\n        },\n        questions,\n        session_id: String(j.id ?? $execution.id).slice(0, 256),\n      },\n    },\n  });\n}\n\nif (out.length === 0) throw new Error('No messages to triage');\nreturn out;\n"
      },
      "id": "c8510318-d79f-4e03-8230-7c28a108f7c1",
      "name": "Build Jev Request",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        736,
        816
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://openrouter.ai/api/alpha/decisions",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "openRouterApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.body_request) }}",
        "options": {
          "response": {
            "response": {
              "neverError": true
            }
          }
        }
      },
      "id": "154adee7-655d-456f-9f42-1609f6cf734f",
      "name": "Call Jev",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.3,
      "position": [
        1152,
        816
      ],
      "credentials": {
        "openRouterApi": {
          "id": "O3r1xV6tLDukbaqy",
          "name": "n8n-jimleuk-20260918"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// --- shared decision core (inlined into each Code node at build time) -------\n// Mirrors TypeSafe's published answer contract:\n//   Choice -> { choice, probabilities, confidence }\n//   Score  -> { score (continuous, probability-weighted), probabilities, confidence }\n//   Noul   -> { noul } : probability of yes, and NO confidence value\n// See https://docs.typesafe.ai/primitives.md\nfunction tsDecision(q, probs, extra) {\n  const probabilities = {};\n  q.candidates.forEach((c, j) => { probabilities[c] = Number(probs[j].toFixed(6)); });\n\n  let bestIdx = 0;\n  for (let j = 1; j < probs.length; j++) if (probs[j] > probs[bestIdx]) bestIdx = j;\n\n  if (q.type === 'noul') {\n    const yesIdx = q.candidates.indexOf('yes');\n    const p = probs[yesIdx >= 0 ? yesIdx : probs.length - 1];\n    // Jev returns no confidence for a Noul: the probability IS the answer.\n    // A value near 0.5 means yes and no are similarly likely - NOT medium intensity.\n    return Object.assign({\n      type: 'noul',\n      noul: Number(p.toFixed(6)),\n      value: p >= 0.5, // convenience for code that needs a hard boolean\n      probabilities,\n    }, extra || {});\n  }\n\n  if (q.type === 'score') {\n    // Jev's `score` is the probability-weighted position on the ordered levels,\n    // not the argmax index - their own docs threshold on `score > 1.5`.\n    return Object.assign({\n      type: 'score',\n      score: Number(probs.reduce((acc, p, j) => acc + p * j, 0).toFixed(6)),\n      level: q.candidates[bestIdx],\n      levelIndex: bestIdx,\n      probabilities,\n      confidence: Number(probs[bestIdx].toFixed(6)),\n    }, extra || {});\n  }\n\n  return Object.assign({\n    type: 'choice',\n    choice: q.candidates[bestIdx],\n    probabilities,\n    confidence: Number(probs[bestIdx].toFixed(6)),\n  }, extra || {});\n}\n\n// Vote counting -> renormalised probabilities over declared candidates only.\nfunction tsProbs(q, tokens) {\n  const counts = {};\n  for (const k of q.keys) counts[k] = 0;\n  for (const t of tokens) {\n    const tok = String(t ?? '').trim().toUpperCase().charAt(0);\n    if (Object.prototype.hasOwnProperty.call(counts, tok)) counts[tok] += 1;\n  }\n  const valid = Object.values(counts).reduce((a, b) => a + b, 0);\n  return {\n    valid,\n    degenerate: valid === 0,\n    probs: valid === 0 ? q.keys.map(() => 1 / q.keys.length) : q.keys.map((k) => counts[k] / valid),\n  };\n}\n\n// Aggregate. Jev excludes Noul from confidence, so ambiguous Nouls are tracked\n// on their own axis rather than folded into minConfidence.\nfunction tsSummary(decisions) {\n  let minConfidence = 1;\n  let maxNoulAmbiguity = 0;\n  for (const d of decisions) {\n    if (d.type === 'noul') {\n      const amb = 1 - Math.abs(2 * d.noul - 1); // 1 at p=0.5, 0 at p=0 or 1\n      if (amb > maxNoulAmbiguity) maxNoulAmbiguity = amb;\n    } else if (d.confidence < minConfidence) {\n      minConfidence = d.confidence;\n    }\n  }\n  return {\n    minConfidence: Number(minConfidence.toFixed(6)),\n    maxNoulAmbiguity: Number(maxNoulAmbiguity.toFixed(6)),\n  };\n}\n\n// Turn Jev's answers into Linear fields, with policy kept in code so thresholds\n// can change without touching the model or the prompt.\n\n// Per-label thresholds. Security is deliberately low: a false positive costs a\n// glance, a false negative costs a breach. Feedback is high: it is noisy.\nconst LABEL_THRESHOLDS = {\n  Security: 0.3,\n  Privacy: 0.4,\n  Complaints: 0.6,\n  Feedback: 0.7,\n  _default: 0.5,\n};\nconst TOOL_THRESHOLD = 0.5;\nconst MIN_SEVERITY_CONFIDENCE = 0.45; // below this, a human picks the priority\nconst MIN_TEAM_CONFIDENCE = 0.5;      // below this, a human picks the owning team\nconst ACTIONABLE_THRESHOLD = 0.5;\nconst SENSITIVE_THRESHOLD = 0.4;      // deliberately low: a miss leaks, a false alarm costs a glance\nconst CREDENTIALS_THRESHOLD = 0.3;    // lower still - a leaked secret is an incident\n\n// Map recommended team -> Linear team id. Fill these in to route for real; any\n// team left null keeps DEFAULT_TEAM_ID so nothing breaks in the meantime.\nconst DEFAULT_TEAM_ID = '1c721608-321d-4132-ac32-6e92d04bb487';\nconst TEAM_IDS = {\n  Platform: null,\n  'Identity & Access': null,\n  'Billing Ops': null,\n  'Product Support': null,\n  Security: null,\n  unassigned: null,\n};\n\nconst meta = $('Build Jev Request').first().json;\nconst res = $input.first().json;\n\nif (!res || typeof res.answers !== 'object') {\n  const msg = res?.error?.message ?? JSON.stringify(res)?.slice(0, 300);\n  throw new Error(`Jev returned no answers: ${msg}`);\n}\nconst a = res.answers;\n\n// --- labels ------------------------------------------------------------------\nconst labelScores = [];\nfor (const [key, ans] of Object.entries(a)) {\n  if (!key.startsWith('label_') || ans?.type !== 'noul') continue;\n  const name = key.slice('label_'.length);\n  const threshold = LABEL_THRESHOLDS[name] ?? LABEL_THRESHOLDS._default;\n  labelScores.push({ name, noul: ans.noul, threshold, selected: ans.noul >= threshold });\n}\nlabelScores.sort((x, y) => y.noul - x.noul);\n\nlet labels = labelScores.filter((l) => l.selected).map((l) => l.name);\nlet noLabelMatched = false;\nif (labels.length === 0 && labelScores.length > 0) {\n  // Never file an unlabelled issue: take the best and flag it.\n  labels = [labelScores[0].name];\n  noLabelMatched = true;\n}\n\n// --- priority ----------------------------------------------------------------\n// Jev's `score` is a continuous position on the 5 severity levels (0..4).\n// Linear's priorityId is 1=Urgent, 2=High, 3=Medium, 4=Low, 0=No Priority.\nconst sev = a.severity ?? {};\nconst score = typeof sev.score === 'number' ? sev.score : null;\nlet priorityId = 3;\nif (score !== null) {\n  if (score >= 3.5) priorityId = 1;\n  else if (score >= 2.5) priorityId = 2;\n  else if (score >= 1.5) priorityId = 3;\n  else priorityId = 4;\n}\nconst severityConfidence = typeof sev.confidence === 'number' ? sev.confidence : null;\nconst basePriorityId = priorityId;\n\n// --- tag (single choice) -----------------------------------------------------\nconst tagAns = a.tag ?? {};\nconst tag = tagAns.choice ?? null;\nconst tagConfidence = typeof tagAns.confidence === 'number' ? tagAns.confidence : null;\n\n// --- team (single choice) ----------------------------------------------------\nconst teamAns = a.team ?? {};\nconst recommendedTeam = teamAns.choice ?? null;\nconst teamConfidence = typeof teamAns.confidence === 'number' ? teamAns.confidence : null;\nconst teamUnassigned = recommendedTeam === 'unassigned';\nconst lowTeamConfidence = teamConfidence !== null && teamConfidence < MIN_TEAM_CONFIDENCE;\nconst teamId = (!teamUnassigned && TEAM_IDS[recommendedTeam]) || DEFAULT_TEAM_ID;\n\n// --- tools (several may apply) -----------------------------------------------\nconst toolScores = [];\nfor (const [key, ans] of Object.entries(a)) {\n  if (!key.startsWith('tool_') || ans?.type !== 'noul') continue;\n  const name = key.slice('tool_'.length).replace(/_/g, ' ');\n  toolScores.push({ name, noul: ans.noul, selected: ans.noul >= TOOL_THRESHOLD });\n}\ntoolScores.sort((x, y) => y.noul - x.noul);\nconst tools = toolScores.filter((t) => t.selected).map((t) => t.name);\n\n// --- gates -------------------------------------------------------------------\nconst p = (k) => (typeof a[k]?.noul === 'number' ? a[k].noul : null);\n\nconst actionableP = p('is_actionable_request');\nconst isActionable = actionableP === null ? true : actionableP >= ACTIONABLE_THRESHOLD;\nconst isReply = (p('is_reply_to_existing_thread') ?? 0) >= 0.5;\n\nconst sensitiveP = p('contains_sensitive_data');\nconst credentialsP = p('contains_credentials');\nconst containsSensitive = (sensitiveP ?? 0) >= SENSITIVE_THRESHOLD;\nconst containsCredentials = (credentialsP ?? 0) >= CREDENTIALS_THRESHOLD;\n\n// --- speculative answers: read only when the matching label fired ------------\n// Everything below was asked in the same call. Code decides what is relevant;\n// irrelevant answers are discarded rather than acted on.\nconst has = (label) => labels.includes(label);\nconst speculative = {};\nif (has('Billing')) {\n  speculative.refundRequested = (p('refund_requested') ?? 0) >= 0.5;\n  speculative.disputedAmount = a.disputed_amount?.choice ?? null;\n}\nif (has('Security')) {\n  speculative.credentialsCompromised = (p('credentials_compromised') ?? 0) >= 0.5;\n  speculative.affectsOtherCustomers = (p('affects_other_customers') ?? 0) >= 0.5;\n}\nif (has('Access')) {\n  speculative.wholeTeamLockedOut = (p('whole_team_locked_out') ?? 0) >= 0.5;\n}\n\n// Blast radius escalates priority regardless of the severity rubric.\nlet priorityEscalation = null;\nif (speculative.affectsOtherCustomers || speculative.credentialsCompromised) priorityEscalation = 'security';\nelse if (speculative.wholeTeamLockedOut) priorityEscalation = 'team-wide';\nelse if (speculative.disputedAmount === 'over_1000') priorityEscalation = 'high-value';\n\n// --- routing -----------------------------------------------------------------\nconst lowSeverityConfidence = severityConfidence !== null && severityConfidence < MIN_SEVERITY_CONFIDENCE;\nconst needsHuman =\n  noLabelMatched || lowSeverityConfidence || teamUnassigned || lowTeamConfidence || containsSensitive || containsCredentials;\n\nconst reasons = [];\nif (noLabelMatched) reasons.push(`no label reached its threshold (best: ${labelScores[0]?.name} at ${labelScores[0]?.noul})`);\nif (lowSeverityConfidence) reasons.push(`severity confidence ${severityConfidence} below ${MIN_SEVERITY_CONFIDENCE}`);\nif (teamUnassigned) reasons.push('no team is a clear fit');\nelse if (lowTeamConfidence) reasons.push(`team confidence ${teamConfidence} below ${MIN_TEAM_CONFIDENCE}`);\nif (containsCredentials) reasons.push(`possible live secret in the message (${credentialsP}) - do not copy verbatim; ask the sender to rotate`);\nelse if (containsSensitive) reasons.push(`possible sensitive data in the message (${sensitiveP}) - redact before filing`);\n\nif (priorityEscalation && priorityId > 1) priorityId = priorityEscalation === 'security' ? 1 : Math.max(1, priorityId - 1);\n\nconst flat = Object.entries(a).map(([question, d]) => Object.assign({ question }, d));\n\nreturn [\n  {\n    json: Object.assign({\n      messageId: meta.messageId,\n      reporterName: meta.reporterName,\n      reporterEmail: meta.reporterEmail,\n      subject: meta.subject,\n      body: meta.body,\n\n      labels,\n      labelScores,\n      noLabelMatched,\n\n      tag,\n      tagConfidence,\n      recommendedTeam,\n      teamConfidence,\n      teamId,\n      tools,\n      toolScores,\n\n      priorityId,\n      basePriorityId,\n      priorityEscalation,\n      severityScore: score,\n      severityConfidence,\n      severityLevel: sev.level ?? null,\n\n      isActionable,\n      isReply,\n      containsSensitive,\n      containsCredentials,\n      sensitiveProbability: sensitiveP,\n      credentialsProbability: credentialsP,\n      speculative,\n\n      needsHuman,\n      reason: reasons.join('; ') || null,\n\n      engine: 'jev',\n      model: res.model ?? null,\n      elapsedMs: Date.now() - (meta.startedAt ?? Date.now()),\n      inputTokens: res.usage?.input_tokens ?? null,\n      cost: res.usage?.cost ?? null,\n    }, tsSummary(flat), { answers: a }),\n  },\n];\n"
      },
      "id": "df3448d8-bae8-413c-9d1d-6fd0c96d04ee",
      "name": "Map Decisions",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1328,
        816
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": false,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 1
          },
          "conditions": [
            {
              "id": "is-actionable",
              "leftValue": "={{ $json.isActionable }}",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "09da9004-907c-4e9d-9b88-27112be5a383",
      "name": "Actionable?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1568,
        816
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": false,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 1
          },
          "conditions": [
            {
              "id": "has-tools",
              "leftValue": "={{ $json.tools }}",
              "operator": {
                "type": "array",
                "operation": "notEmpty",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "1620ee16-bd2a-4334-bea0-04d7d71d93e8",
      "name": "Any Tools To Run?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1792,
        720
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "route",
              "name": "route",
              "type": "string",
              "value": "discarded"
            }
          ]
        },
        "includeOtherFields": true,
        "options": {}
      },
      "id": "c205c82c-9227-4084-a2dd-d16201d7f6e5",
      "name": "Not Actionable",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        1792,
        912
      ]
    },
    {
      "parameters": {
        "jsCode": "// Fan out to one item per tool Jev thought worth reaching for.\n// Each item carries everything the HTTP node needs to make the call.\nconst decisions = $('Map Decisions').first().json;\n\n// In a real deployment each of these is a different API with its own credential.\n// The registry keeps that mapping in one place instead of five branches.\nconst REGISTRY = {\n  Sentry: {\n    url: 'https://sentry.io/api/0/projects/acme/backend/issues/',\n    query: (d) => ({ query: `is:unresolved user.email:${d.reporterEmail ?? ''}`, statsPeriod: '24h' }),\n  },\n  'Stripe Dashboard': {\n    url: 'https://api.stripe.com/v1/invoices',\n    query: (d) => ({ customer_email: d.reporterEmail ?? '', limit: 5 }),\n  },\n  'Identity Admin': {\n    url: 'https://identity.internal/api/v1/users/lookup',\n    query: (d) => ({ email: d.reporterEmail ?? '', include: 'sessions,lockouts' }),\n  },\n};\n\nconst out = [];\nfor (const name of decisions.tools ?? []) {\n  const entry = REGISTRY[name];\n  if (!entry) continue; // unknown tool: skip rather than fail the run\n  out.push({\n    json: {\n      toolName: name,\n      url: entry.url,\n      query: entry.query(decisions),\n      messageId: decisions.messageId,\n      startedAt: Date.now(),\n    },\n  });\n}\n\nif (out.length === 0) throw new Error('Any Tools To Run? should have routed around this node');\nreturn out;\n"
      },
      "id": "dd6eae3a-aa3e-41c0-b2da-7d25c23ea656",
      "name": "Select Tools",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2016,
        480
      ]
    },
    {
      "parameters": {
        "jsCode": "// Assemble the final Linear issue.\n//\n// There is no generative step in this pipeline. Every field below is either a\n// typed decision from Jev or the customer's own words, copied verbatim - so\n// nothing here can be invented.\nlet decisions;\ntry { decisions = $('Map Follow-up').first().json; } catch { decisions = $('Map Decisions').first().json; }\n\nconst tags = decisions.labels.map((l) => `#${l}`).join(' ');\nconst toolLine = decisions.tools?.length\n  ? `Suggested tools: ${decisions.tools.join(', ')}`\n  : 'Suggested tools: none above threshold';\n\nconst spec = decisions.speculative ?? {};\nconst specLines = Object.entries(spec)\n  .filter(([, v]) => v !== null && v !== false)\n  .map(([k, v]) => `  - ${k}: ${v}`);\n\nconst fu = decisions.followUp;\n\nconst provenance = [\n  '',\n  '---',\n  `Reported by ${decisions.reporterName ?? 'unknown'} <${decisions.reporterEmail ?? ''}>`,\n  `Suggested team: ${decisions.recommendedTeam ?? 'unassigned'} (confidence ${decisions.teamConfidence ?? '?'})`,\n  `Tag: ${decisions.tag ?? 'unclear'} (confidence ${decisions.tagConfidence ?? '?'})`,\n  toolLine,\n  decisions.priorityEscalation\n    ? `Priority escalated (${decisions.priorityEscalation}) from ${decisions.basePriorityId} to ${decisions.priorityId}`\n    : null,\n  fu?.ran ? `Tools run: ${fu.toolsRun.join(', ') || 'none'}` : null,\n  fu?.ran ? `Next action: ${fu.nextAction ?? 'file_issue'} (confidence ${fu.nextActionConfidence ?? '?'})` : null,\n  fu?.knownIncident ? '⚠️ Evidence matches a known incident - link rather than duplicate.' : null,\n  fu?.userError ? 'Evidence suggests the system behaved correctly.' : null,\n  fu?.changes?.length ? `Revised after evidence: ${fu.changes.join('; ')}` : null,\n  fu?.toolErrors?.length ? `Tool errors: ${fu.toolErrors.join('; ')}` : null,\n  specLines.length ? 'Branch findings:' : null,\n  ...specLines,\n  decisions.containsCredentials ? '⚠️ Possible live secret in the original message - ask the sender to rotate.' : null,\n  decisions.containsSensitive ? '⚠️ Possible sensitive data in the original message - redact before sharing further.' : null,\n  `Triaged by ${decisions.model ?? 'jev'} · severity ${decisions.severityScore ?? '?'} (confidence ${decisions.severityConfidence ?? '?'})`,\n  'Message text is the customer\\'s own, copied verbatim.',\n  tags,\n].filter((l) => l !== null).join('\\n');\n\nreturn [\n  {\n    json: Object.assign({}, decisions, {\n      title: String(decisions.subject ?? '(no subject)').slice(0, 250),\n      description: `${decisions.body}\\n${provenance}`,\n    }),\n  },\n];\n"
      },
      "id": "b387e67b-9d73-4b48-93d5-fe2c8206afd8",
      "name": "Finalise Issue",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3440,
        720
      ]
    },
    {
      "parameters": {
        "options": {}
      },
      "id": "b4e0df41-e0b0-4a5d-9a3b-0e837ee8f497",
      "name": "Loop Over Tools",
      "type": "n8n-nodes-base.splitInBatches",
      "typeVersion": 3,
      "position": [
        2240,
        480
      ]
    },
    {
      "parameters": {
        "jsCode": "// Collapse the loop's accumulated results into one findings object.\nconst selected = $('Select Tools').all().map((i) => i.json.toolName);\nconst results = $input.all().map((i) => i.json);\n\nconst findings = {};\nconst errors = [];\n\nfor (const r of results) {\n  if (!r?.tool) continue;\n  if (r.error) { errors.push(`${r.tool}: ${r.error}`); continue; }\n  if (r.finding == null) { errors.push(`${r.tool}: no data returned`); continue; }\n  findings[r.tool] = r.finding;\n}\n\n// A tool that never came back at all is worth recording too.\nfor (const name of selected) {\n  if (!(name in findings) && !errors.some((e) => e.startsWith(name + ':'))) {\n    errors.push(`${name}: no response`);\n  }\n}\n\nreturn [\n  {\n    json: {\n      toolsRun: selected,\n      toolsAnswered: Object.keys(findings),\n      toolErrors: errors,\n      findings,\n    },\n  },\n];\n"
      },
      "id": "d01fe291-cffb-4bfe-beea-99395d6f4c36",
      "name": "Gather Findings",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2432,
        480
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 1
                },
                "conditions": [
                  {
                    "id": "is-Sentry",
                    "leftValue": "={{ $json.toolName }}",
                    "rightValue": "Sentry",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Sentry"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 1
                },
                "conditions": [
                  {
                    "id": "is-Stripe Dashboard",
                    "leftValue": "={{ $json.toolName }}",
                    "rightValue": "Stripe Dashboard",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Stripe"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 1
                },
                "conditions": [
                  {
                    "id": "is-Identity Admin",
                    "leftValue": "={{ $json.toolName }}",
                    "rightValue": "Identity Admin",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Identity"
            }
          ]
        },
        "options": {
          "fallbackOutput": "extra",
          "renameFallbackOutput": "Unknown"
        }
      },
      "id": "112cf3eb-1e2d-4952-bf82-d760d89e3736",
      "name": "Route Tool",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        2432,
        704
      ]
    },
    {
      "parameters": {
        "resource": "issue",
        "operation": "getAll",
        "organizationSlug": "acme",
        "projectSlug": "backend",
        "limit": 5,
        "additionalFields": {
          "query": "={{ \"user.email:\" + $json.query.query }}",
          "statsPeriod": "24h"
        }
      },
      "id": "aefcfee1-089b-4348-aae1-b2311c92cd42",
      "name": "Sentry",
      "type": "n8n-nodes-base.sentryIo",
      "typeVersion": 1,
      "position": [
        2656,
        704
      ]
    },
    {
      "parameters": {
        "resource": "customer",
        "operation": "getAll",
        "limit": 5,
        "filters": {
          "email": "={{ $json.query.customer_email }}"
        }
      },
      "id": "cb82ffd4-a4ca-47b2-b594-446a4f6ca802",
      "name": "Stripe",
      "type": "n8n-nodes-base.stripe",
      "typeVersion": 1,
      "position": [
        2736,
        832
      ]
    },
    {
      "parameters": {
        "url": "={{ $json.url }}",
        "sendQuery": true,
        "specifyQuery": "json",
        "jsonQuery": "={{ JSON.stringify($json.query) }}",
        "options": {
          "response": {
            "response": {
              "neverError": true
            }
          }
        }
      },
      "id": "5a0461bd-45a7-4416-b881-f11b1bc14a33",
      "name": "Identity Admin",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.3,
      "position": [
        2816,
        960
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "error",
              "name": "error",
              "type": "string",
              "value": "no node wired for this tool"
            }
          ]
        },
        "includeOtherFields": true,
        "options": {}
      },
      "id": "48dfb9ad-ed52-4e6f-baf3-3f40aa6fc7ad",
      "name": "Unknown Tool",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        2880,
        1088
      ]
    },
    {
      "parameters": {
        "jsCode": "// One loop iteration: whatever the service node returned, reduce it to\n// { tool, finding } so downstream code does not care which node produced it.\nconst item = $('Loop Over Tools').first().json;\nconst toolName = item.toolName ?? $json.tool ?? 'unknown';\nconst res = $input.first().json ?? {};\n\n// Service nodes each return their own shape. Normalise the ones we know, and\n// pass anything else through whole rather than dropping information.\nlet finding;\nif (res.error) {\n  finding = null;\n} else if (res.finding) {\n  finding = res.finding;                       // already normalised (pinned fixtures)\n} else if (Array.isArray(res)) {\n  finding = { count: res.length, items: res.slice(0, 5) };\n} else {\n  finding = res;\n}\n\nreturn [\n  {\n    json: {\n      tool: toolName,\n      finding,\n      error: res.error ? String(res.error.message ?? res.error) : null,\n    },\n  },\n];\n"
      },
      "id": "9e7b43fc-a650-4f6f-95fd-8740066d96aa",
      "name": "Normalise Finding",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3104,
        1088
      ]
    },
    {
      "parameters": {
        "jsCode": "// Second Jev pass: re-decide now that there is evidence, not just an email.\n//\n// These questions are only answerable WITH tool output. That is the point of the\n// loop - pass one decides what to look at, pass two decides what it means.\nconst MODEL = '~typesafe/jev-latest';\n\nconst decisions = $('Map Decisions').first().json;\nconst gathered = $input.first().json;\n\nreturn [\n  {\n    json: {\n      startedAt: Date.now(),\n      toolsRun: gathered.toolsRun,\n      toolErrors: gathered.toolErrors,\n      body_request: {\n        model: MODEL,\n        state: {\n          original_message: decisions.body,\n          reported_by: decisions.reporterEmail,\n          first_pass: {\n            labels: decisions.labels,\n            severity: decisions.severityScore,\n            suggested_team: decisions.recommendedTeam,\n            tag: decisions.tag,\n          },\n          tool_findings: gathered.findings,\n        },\n        questions: {\n          findings_explain_problem: {\n            type: 'noul',\n            instructions:\n              'Do `tool_findings` explain what the customer reported in `original_message`? Answer yes only if the evidence identifies a plausible cause, not merely that something was recorded.',\n          },\n          is_known_incident: {\n            type: 'noul',\n            instructions:\n              'Do `tool_findings` indicate this is part of an already-known incident or outage, rather than a problem unique to this customer?',\n          },\n          user_error_confirmed: {\n            type: 'noul',\n            instructions:\n              'Do `tool_findings` show the system behaved correctly and the problem was caused by how the customer used it?',\n          },\n          needs_more_information: {\n            type: 'noul',\n            instructions:\n              'Is there still a material gap that has to be filled by asking the customer or running another check before anyone can act?',\n          },\n          revised_team: {\n            type: 'choice',\n            instructions:\n              'Given the evidence in `tool_findings`, which team should own this now? This may differ from `first_pass.suggested_team`.',\n            criteria: {\n              Platform: 'core infrastructure, APIs, performance, outages',\n              'Identity & Access': 'login, SSO, passwords, permissions, sessions',\n              'Billing Ops': 'invoices, charges, refunds, subscriptions, seats',\n              'Product Support': 'how-to questions, training, general product guidance',\n              Security: 'suspected compromise, vulnerabilities, unauthorised access',\n              unassigned: 'the evidence still does not point to one team',\n            },\n          },\n          revised_severity: {\n            type: 'score',\n            instructions: 'Given the evidence, how severe is the impact on the customer right now?',\n            criteria: [\n              'no real impact; cosmetic, informational, or already resolved',\n              'minor inconvenience with an easy workaround',\n              'meaningful disruption to normal work',\n              'a core workflow is blocked with no workaround',\n              'business-critical; money, data, or security is actively at risk',\n            ],\n          },\n          next_action: {\n            type: 'choice',\n            instructions: 'What should happen next with this ticket?',\n            criteria: {\n              file_issue: 'create an engineering issue; there is enough to work on',\n              link_to_incident: 'attach to the known incident and tell the customer',\n              ask_customer: 'go back to the customer for missing information',\n              resolve_without_issue: 'no engineering work needed; answer the customer directly',\n              escalate_security: 'route to security immediately, ahead of normal triage',\n            },\n          },\n        },\n        session_id: String(decisions.messageId ?? $execution.id).slice(0, 256),\n      },\n    },\n  },\n];\n"
      },
      "id": "a676c675-73f3-4613-96dc-a86aaa6103d5",
      "name": "Build Follow-up Request",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2656,
        480
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://openrouter.ai/api/alpha/decisions",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "openRouterApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.body_request) }}",
        "options": {
          "response": {
            "response": {
              "neverError": true
            }
          }
        }
      },
      "id": "dec26f34-749a-4630-b088-fb9b8401cc2a",
      "name": "Follow-up Decisions",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.3,
      "position": [
        2880,
        480
      ],
      "credentials": {
        "openRouterApi": {
          "id": "O3r1xV6tLDukbaqy",
          "name": "n8n-jimleuk-20260918"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// --- shared decision core (inlined into each Code node at build time) -------\n// Mirrors TypeSafe's published answer contract:\n//   Choice -> { choice, probabilities, confidence }\n//   Score  -> { score (continuous, probability-weighted), probabilities, confidence }\n//   Noul   -> { noul } : probability of yes, and NO confidence value\n// See https://docs.typesafe.ai/primitives.md\nfunction tsDecision(q, probs, extra) {\n  const probabilities = {};\n  q.candidates.forEach((c, j) => { probabilities[c] = Number(probs[j].toFixed(6)); });\n\n  let bestIdx = 0;\n  for (let j = 1; j < probs.length; j++) if (probs[j] > probs[bestIdx]) bestIdx = j;\n\n  if (q.type === 'noul') {\n    const yesIdx = q.candidates.indexOf('yes');\n    const p = probs[yesIdx >= 0 ? yesIdx : probs.length - 1];\n    // Jev returns no confidence for a Noul: the probability IS the answer.\n    // A value near 0.5 means yes and no are similarly likely - NOT medium intensity.\n    return Object.assign({\n      type: 'noul',\n      noul: Number(p.toFixed(6)),\n      value: p >= 0.5, // convenience for code that needs a hard boolean\n      probabilities,\n    }, extra || {});\n  }\n\n  if (q.type === 'score') {\n    // Jev's `score` is the probability-weighted position on the ordered levels,\n    // not the argmax index - their own docs threshold on `score > 1.5`.\n    return Object.assign({\n      type: 'score',\n      score: Number(probs.reduce((acc, p, j) => acc + p * j, 0).toFixed(6)),\n      level: q.candidates[bestIdx],\n      levelIndex: bestIdx,\n      probabilities,\n      confidence: Number(probs[bestIdx].toFixed(6)),\n    }, extra || {});\n  }\n\n  return Object.assign({\n    type: 'choice',\n    choice: q.candidates[bestIdx],\n    probabilities,\n    confidence: Number(probs[bestIdx].toFixed(6)),\n  }, extra || {});\n}\n\n// Vote counting -> renormalised probabilities over declared candidates only.\nfunction tsProbs(q, tokens) {\n  const counts = {};\n  for (const k of q.keys) counts[k] = 0;\n  for (const t of tokens) {\n    const tok = String(t ?? '').trim().toUpperCase().charAt(0);\n    if (Object.prototype.hasOwnProperty.call(counts, tok)) counts[tok] += 1;\n  }\n  const valid = Object.values(counts).reduce((a, b) => a + b, 0);\n  return {\n    valid,\n    degenerate: valid === 0,\n    probs: valid === 0 ? q.keys.map(() => 1 / q.keys.length) : q.keys.map((k) => counts[k] / valid),\n  };\n}\n\n// Aggregate. Jev excludes Noul from confidence, so ambiguous Nouls are tracked\n// on their own axis rather than folded into minConfidence.\nfunction tsSummary(decisions) {\n  let minConfidence = 1;\n  let maxNoulAmbiguity = 0;\n  for (const d of decisions) {\n    if (d.type === 'noul') {\n      const amb = 1 - Math.abs(2 * d.noul - 1); // 1 at p=0.5, 0 at p=0 or 1\n      if (amb > maxNoulAmbiguity) maxNoulAmbiguity = amb;\n    } else if (d.confidence < minConfidence) {\n      minConfidence = d.confidence;\n    }\n  }\n  return {\n    minConfidence: Number(minConfidence.toFixed(6)),\n    maxNoulAmbiguity: Number(maxNoulAmbiguity.toFixed(6)),\n  };\n}\n\n// Merge the evidence-informed second pass over the first-pass decisions.\n// First pass judged an email. Second pass judged an email plus evidence, so it\n// wins on anything they disagree about - but the change is always recorded.\nconst REVISED_TEAM_MIN = 0.5;\nconst FINDINGS_MIN = 0.5;\n\nconst first = $('Map Decisions').first().json;\nconst req = $('Build Follow-up Request').first().json;\nconst res = $input.first().json;\n\nif (!res || typeof res.answers !== 'object') {\n  const msg = res?.error?.message ?? JSON.stringify(res)?.slice(0, 300);\n  throw new Error(`Follow-up pass returned no answers: ${msg}`);\n}\nconst a = res.answers;\nconst p = (k) => (typeof a[k]?.noul === 'number' ? a[k].noul : null);\n\nconst findingsExplain = (p('findings_explain_problem') ?? 0) >= FINDINGS_MIN;\nconst knownIncident = (p('is_known_incident') ?? 0) >= FINDINGS_MIN;\nconst userError = (p('user_error_confirmed') ?? 0) >= FINDINGS_MIN;\nconst needsMoreInfo = (p('needs_more_information') ?? 0) >= FINDINGS_MIN;\n\nconst nextAction = a.next_action?.choice ?? null;\nconst nextActionConfidence = a.next_action?.confidence ?? null;\n\n// --- revised team -------------------------------------------------------------\nconst revisedTeamAns = a.revised_team ?? {};\nconst revisedTeam = revisedTeamAns.choice ?? null;\nconst revisedTeamConfidence = revisedTeamAns.confidence ?? null;\nconst teamChanged =\n  revisedTeam && revisedTeam !== 'unassigned' && revisedTeam !== first.recommendedTeam &&\n  (revisedTeamConfidence ?? 0) >= REVISED_TEAM_MIN;\nconst finalTeam = teamChanged ? revisedTeam : first.recommendedTeam;\n\n// --- revised severity ---------------------------------------------------------\nconst revSev = a.revised_severity ?? {};\nconst revisedScore = typeof revSev.score === 'number' ? revSev.score : null;\nlet priorityId = first.priorityId;\nlet priorityChanged = false;\nif (revisedScore !== null) {\n  const mapped = revisedScore >= 3.5 ? 1 : revisedScore >= 2.5 ? 2 : revisedScore >= 1.5 ? 3 : 4;\n  // Security escalation from pass one is not undone by a calmer second read.\n  if (first.priorityEscalation !== 'security' && mapped !== first.priorityId) {\n    priorityId = mapped;\n    priorityChanged = true;\n  }\n}\n\n// --- what happens next --------------------------------------------------------\n// Only file_issue continues to Linear. Everything else is a different outcome\n// that the old workflow had no way to express - it filed an issue regardless.\nconst fileIssue = nextAction === null || nextAction === 'file_issue';\nconst needsHuman =\n  first.needsHuman || needsMoreInfo || nextAction === 'ask_customer' || nextAction === 'escalate_security';\n\nconst changes = [];\nif (teamChanged) changes.push(`team ${first.recommendedTeam} -> ${revisedTeam}`);\nif (priorityChanged) changes.push(`priority ${first.priorityId} -> ${priorityId}`);\nif (knownIncident) changes.push('matched a known incident');\nif (userError) changes.push('evidence points to user error');\n\nreturn [\n  {\n    json: Object.assign({}, first, {\n      // second pass wins\n      recommendedTeam: finalTeam,\n      teamConfidence: teamChanged ? revisedTeamConfidence : first.teamConfidence,\n      priorityId,\n      needsHuman,\n\n      followUp: {\n        ran: true,\n        toolsRun: req.toolsRun ?? [],\n        toolErrors: req.toolErrors ?? [],\n        findingsExplain,\n        knownIncident,\n        userError,\n        needsMoreInfo,\n        nextAction,\n        nextActionConfidence,\n        revisedTeam,\n        revisedTeamConfidence,\n        revisedScore,\n        teamChanged,\n        priorityChanged,\n        changes,\n        elapsedMs: Date.now() - (req.startedAt ?? Date.now()),\n        cost: res.usage?.cost ?? null,\n      },\n\n      fileIssue,\n      reason: [first.reason, needsMoreInfo ? 'evidence is still incomplete' : null,\n        nextAction && nextAction !== 'file_issue' ? `next action: ${nextAction}` : null]\n        .filter(Boolean).join('; ') || null,\n    }, tsSummary(Object.values(a).map((d) => d))),\n  },\n];\n"
      },
      "id": "dd252cc5-23ea-4f66-9f04-c7bafd56401d",
      "name": "Map Follow-up",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3104,
        480
      ]
    },
    {
      "parameters": {
        "teamId": "={{ $json.teamId }}",
        "title": "={{ $json.title }}",
        "additionalFields": {
          "description": "={{ $json.description }}",
          "priorityId": "={{ $json.priorityId }}",
          "stateId": "92962324-3d1f-4cf8-993b-0c982cc95245"
        }
      },
      "id": "63937092-2b2c-475c-9fb2-1163b11c5292",
      "name": "Create Issue in Linear.App",
      "type": "n8n-nodes-base.linear",
      "typeVersion": 1,
      "position": [
        3616,
        720
      ]
    },
    {
      "parameters": {
        "jsCode": "// The issue is always filed. This records what happened and decides whether a\n// human needs to look at it on top - escalation, not a gate.\nconst decisions = $('Finalise Issue').first().json;\nconst issue = $input.first().json ?? {};\n\nconst identifier = issue.identifier ?? null;\nconst issueId = issue.id ?? null;\nconst created = Boolean(identifier || issueId);\n\nconst fu = decisions.followUp;\nconst nextAction = fu?.nextAction ?? null;\n\n// Two separate reasons to pull a person in:\n//  1. the triage itself was not confident enough to stand on its own\n//  2. the evidence says an engineering issue is not the right response\nconst notConfident = Boolean(decisions.needsHuman);\nconst wrongOutcome = decisions.fileIssue === false;\nconst needsEscalation = notConfident || wrongOutcome || !created;\n\nconst reasons = [];\nif (!created) reasons.push('Linear did not return an issue - confirm it was filed');\nif (decisions.reason) reasons.push(decisions.reason);\nif (wrongOutcome && nextAction) reasons.push(`evidence suggests \"${nextAction}\" rather than a new issue`);\n\nreturn [\n  {\n    json: {\n      issueId,\n      issueIdentifier: identifier,\n      issueUrl: issue.url ?? (identifier ? `https://linear.app/issue/${identifier}` : null),\n      issueTitle: issue.title ?? decisions.title ?? null,\n      created,\n\n      needsEscalation,\n      escalationReason: reasons.join('; ') || null,\n      escalationKind: !created ? 'filing_failed' : wrongOutcome ? 'wrong_outcome' : notConfident ? 'low_confidence' : null,\n\n      // carried through so whoever picks this up has the triage in front of them\n      priorityId: decisions.priorityId,\n      labels: decisions.labels,\n      recommendedTeam: decisions.recommendedTeam,\n      tag: decisions.tag,\n      tools: decisions.tools,\n      nextAction,\n      minConfidence: decisions.minConfidence,\n      containsCredentials: decisions.containsCredentials,\n      containsSensitive: decisions.containsSensitive,\n      messageId: decisions.messageId,\n      reporterEmail: decisions.reporterEmail,\n    },\n  },\n];\n"
      },
      "id": "9250cf20-fde0-457d-983e-fca3a7bc7e1b",
      "name": "Record Outcome",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3792,
        720
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": false,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 1
          },
          "conditions": [
            {
              "id": "needs-escalation",
              "leftValue": "={{ $json.needsEscalation }}",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "0881005b-e0e9-4ac2-95c0-78b2e6e08dcf",
      "name": "Needs Escalation?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        3968,
        720
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "route",
              "name": "route",
              "type": "string",
              "value": "escalated"
            }
          ]
        },
        "includeOtherFields": true,
        "options": {}
      },
      "id": "e99d9706-07cc-4eb6-9df3-6782ffb3a56b",
      "name": "Escalate To Human",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        4160,
        640
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "route",
              "name": "route",
              "type": "string",
              "value": "filed"
            }
          ]
        },
        "includeOtherFields": true,
        "options": {}
      },
      "id": "90d75143-a27a-43f1-8b7e-10aaf990f2cf",
      "name": "Filed",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        4160,
        816
      ]
    },
    {
      "parameters": {
        "content": "# Customer Support Emails to Linear using Jev\n\nThis template classifies support emails without an LLM. Instead it uses Jev - a system one model by [Typesafe.ai](https://typesafe.ai?ref=jimleuk).\n\n**Highlights**\n* Immune to prompt injections\n* drastically lower risk of hallucinations\n* multitudes faster and cheaper than frontier LLMs\n* On par and arguable exceeds LLM-based classification accuracy\n\n## In numbers\n* 27 questions with 63 choices in one call to Jev.\n* 3 tools usage examples\n* 7 more questions in follow-up request.\n* Averaging 1 second per email\n\n## How it works\n* A schedule trigger brings in a batch of emails from a support request inbox.\n* We define a Jev request: a payload of classification criteria and signals which we'll use to understand the request and file the appropriate ticket.\n* The payload along with the customer message is sent to Jev via OpenRouter API. Jev is able to score all 27 questions in an average of 500ms.\n* As part of our classification, we ask Jev to score against tools which may help our investigation. Any tools with a high probability score are executed in a separate loop and their responses used for further clarification. A great example of tool usage with LLMs!\n* Finally, the issue is created and dependent on urgency and priority, we continue to route to human escalation (not implemented in this template) \n\n## How to use\n* Integrate this into your support stream and see the difference in speed and cost.\n* Look at what changes with security and compliance when LLMs are not involved.\n* Try this with other workflows where you may be using LLMs to classify.\n\n## Requirements\n* OpenRouter for Jev model. Can be swapped out for alternative provider.\n* Gmail for email input. Or use outlook ([example](https://n8n.io/workflows/3893-automatically-create-jira-issues-from-outlook-email-support-requests/))\n* Linear for ticket creation or alternatively use Jira.\n\n## Extending this workflow\n* Use LLMs for generating responses\n* Use LLMs to format issue and add suggested resolutions",
        "height": 1168,
        "width": 672
      },
      "id": "43276730-47ac-4fd3-b5e9-1f0168b609c9",
      "name": "How this works",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -912,
        -64
      ]
    },
    {
      "parameters": {
        "content": "## 1. Email Support Emails as Input\nSupport email routing is a well understood classification use-case which we've commonly used LLMs for in the past few years. Using generative AI does come with it's problems: requiring more expensive models to get right, many guardrails needed etc.\n\nLet's see how we might solve this with Jev.",
        "height": 432,
        "width": 704,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -192,
        608
      ],
      "id": "ea65d648-817b-4bff-8634-bf4143a1baf7",
      "name": "Sticky Note"
    },
    {
      "parameters": {
        "content": "## 2. You Define Classification Rules\nHere, we've specified 27 questions with 63 total choices aimed at identifying the issue, finding evidence, measuring priority, matching against the best team and defining outcomes.\n\nThe trick here is to ensure you're asking the right questions!",
        "height": 560,
        "width": 496,
        "color": 4
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        528,
        480
      ],
      "id": "03c99878-7616-4acb-b0f9-9b5531b81370",
      "name": "Sticky Note1"
    },
    {
      "parameters": {
        "content": "## 3. JEV acts as Classifier, Filter and Tool Router\nJev is different in that rather than generating an answer like an LLM (which would typically exceed context window and fail), it returns probabilities. This allows the model to answer all 27 questions in milliseconds and near to no cost.\n\nIf we're clever with the classifications, we can use them to help us filter the data and even define tool usage by simply defining yes/no questions. This can greatly reduce the instances of LLMs we need to use in our workflows.",
        "height": 560,
        "width": 496,
        "color": 5
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1040,
        480
      ],
      "id": "7b8407b9-21e6-4543-a186-acbcecb54d88",
      "name": "Sticky Note2"
    },
    {
      "parameters": {
        "content": "## 4. Tool Usage without an LLM\nIn our initial request, we told Jev to decide which tools would help in further investigating the issue. Like LLM-powered agent loops, we can simple execute those calls outside of Jev and bring them back to Jev to reevaluate - a Jev-agent if you will!",
        "height": 1072,
        "width": 1376,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1968,
        288
      ],
      "id": "84be7f61-b164-4478-adee-da3f5c731a85",
      "name": "Sticky Note3"
    },
    {
      "parameters": {
        "content": "JEV classifies tool results",
        "height": 256,
        "width": 256,
        "color": 5
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2800,
        416
      ],
      "id": "058759e5-8c4e-4c7f-8188-f75521459bd5",
      "name": "Sticky Note4"
    },
    {
      "parameters": {
        "content": "## 5. Create Issue and Human Escalation\nFinally, we can create our issue in Linear or a similar issue tracker and optionally escalate to a human if necessary.\nWhat we are missing from this template however are the ability for automated responses, rewrites or resolution and where Generative AI still justifies its place.\n",
        "height": 544,
        "width": 1008,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        3376,
        480
      ],
      "id": "ee7bc086-9dd0-43ef-aa51-835a30e4d714",
      "name": "Sticky Note5"
    },
    {
      "parameters": {
        "content": "## ⚠️ OpenRouter Requirement\nWe're using Jev via OpenRouter so you'll need an OpenRouter key and credits. Alternatively, swap this out for another Jev provider.",
        "height": 144,
        "width": 432,
        "color": 6
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1040,
        320
      ],
      "id": "27c9fe6d-3ae9-4524-bc14-b97d8f52b715",
      "name": "Sticky Note6"
    },
    {
      "parameters": {
        "content": "[![](https://cdn.subworkflow.ai/marketing/banner-300x100.png?v=20260918)](https://subworkflow.ai)",
        "height": 128,
        "width": 336,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -912,
        1120
      ],
      "id": "d94143f7-5d86-4a1e-9d37-73cf46544ccf",
      "name": "Sticky Note7"
    }
  ],
  "pinData": {
    "Get Recent Messages": [
      {
        "json": {
          "id": "18f2a9c1b0e4d701",
          "threadId": "18f2a9c1b0e4d701",
          "labelIds": [
            "UNREAD",
            "INBOX"
          ],
          "sizeEstimate": 4821,
          "messageId": "<CAF7h1a@mail.acme.com>",
          "date": "2026-09-18T08:14:02.000Z",
          "subject": "STILL can't log in - this is the fourth time I'm asking",
          "from": {
            "value": [
              {
                "address": "jane.doe@acme.com",
                "name": "Jane Doe"
              }
            ],
            "html": "<span class=\"mp_address_group\">Jane Doe</span>",
            "text": "Jane Doe <jane.doe@acme.com>"
          },
          "to": {
            "value": [
              {
                "address": "support@example.com",
                "name": ""
              }
            ],
            "html": "<span>support@example.com</span>",
            "text": "support@example.com"
          },
          "headers": {
            "subject": "Subject: STILL can't log in",
            "from": "From: Jane Doe <jane.doe@acme.com>"
          },
          "html": "<p>This is honestly unacceptable. I have reset my password THREE times now and it still throws 'invalid credentials'. I got a notification about a login from a device I don't recognise, then got locked out.</p><p>Nobody has replied in three days. We are paying for the enterprise plan and my whole team is blocked. This is costing us money every single hour.</p>",
          "text": "This is honestly unacceptable. I have reset my password THREE times now and it still throws 'invalid credentials'. I got a notification about a login from a device I don't recognise, then got locked out. Nobody has replied in three days. We are paying for the enterprise plan and my whole team is blocked. This is costing us money every single hour."
        }
      },
      {
        "json": {
          "id": "18f2a9c1b0e4d702",
          "threadId": "18f2a9c1b0e4d702",
          "labelIds": [
            "UNREAD",
            "INBOX"
          ],
          "sizeEstimate": 2104,
          "messageId": "<CAF7h2b@mail.globex.com>",
          "date": "2026-09-18T09:02:41.000Z",
          "subject": "Question about invoice INV-4471 line item",
          "from": {
            "value": [
              {
                "address": "ap@globex.com",
                "name": "Globex Accounts Payable"
              }
            ],
            "html": "<span class=\"mp_address_group\">Globex Accounts Payable</span>",
            "text": "Globex Accounts Payable <ap@globex.com>"
          },
          "to": {
            "value": [
              {
                "address": "support@example.com",
                "name": ""
              }
            ],
            "html": "<span>support@example.com</span>",
            "text": "support@example.com"
          },
          "headers": {
            "subject": "Subject: Question about invoice INV-4471",
            "from": "From: Globex Accounts Payable <ap@globex.com>"
          },
          "html": "<p>Invoice INV-4471 dated 1 September shows a line item 'Additional seats - 12' at £480. Our records show 8 seats were added on 28 August.</p><p>Could you confirm the seat count used for that line, and whether the 1 September proration applies? Happy to pay once confirmed.</p>",
          "text": "Invoice INV-4471 dated 1 September shows a line item 'Additional seats - 12' at £480. Our records show 8 seats were added on 28 August. Could you confirm the seat count used for that line, and whether the 1 September proration applies? Happy to pay once confirmed."
        }
      },
      {
        "json": {
          "id": "18f2a9c1b0e4d703",
          "threadId": "18f2a9c1b0e4d703",
          "labelIds": [
            "UNREAD",
            "INBOX"
          ],
          "sizeEstimate": 890,
          "messageId": "<CAF7h3c@mail.initech.com>",
          "date": "2026-09-18T09:47:18.000Z",
          "subject": "quick one",
          "from": {
            "value": [
              {
                "address": "m.bolton@initech.com",
                "name": "Michael Bolton"
              }
            ],
            "html": "<span class=\"mp_address_group\">Michael Bolton</span>",
            "text": "Michael Bolton <m.bolton@initech.com>"
          },
          "to": {
            "value": [
              {
                "address": "support@example.com",
                "name": ""
              }
            ],
            "html": "<span>support@example.com</span>",
            "text": "support@example.com"
          },
          "headers": {
            "subject": "Subject: quick one",
            "from": "From: Michael Bolton <m.bolton@initech.com>"
          },
          "html": "<p>Hey - is the thing we discussed last week sorted yet? Let me know.</p>",
          "text": "Hey - is the thing we discussed last week sorted yet? Let me know."
        }
      }
    ],
    "Sentry": [
      {
        "json": {
          "tool": "Sentry",
          "finding": {
            "issues": 1,
            "top_issue": "AuthTokenExpiredError in session-service",
            "events_24h": 2841,
            "affected_users": 612,
            "first_seen": "2026-09-18T06:40:00Z",
            "summary": "Spike in session token validation failures across 612 users starting 06:40Z."
          }
        }
      }
    ],
    "Stripe": [
      {
        "json": {
          "tool": "Stripe Dashboard",
          "finding": {
            "invoices": [],
            "summary": "No invoices or payment activity in the last 90 days for this email."
          }
        }
      }
    ],
    "Identity Admin": [
      {
        "json": {
          "tool": "Identity Admin",
          "finding": {
            "account_status": "locked",
            "locked_at": "2026-09-18T08:02:00Z",
            "lock_reason": "failed_login_threshold",
            "password_reset_attempts": 3,
            "active_sessions": 0,
            "mfa_enabled": false,
            "summary": "Account locked by the failed-login threshold. Three password resets completed successfully; each subsequent login still failed."
          }
        }
      }
    ],
    "Create Issue in Linear.App": [
      {
        "json": {
          "success": true,
          "id": "8a1f3c92-4d77-4a1b-9c2e-5f0b7d6e1a23",
          "identifier": "SUP-412",
          "title": "[MOCKED] Issue not created - Linear node is pinned",
          "priority": 1,
          "archivedAt": null,
          "assignee": null,
          "state": {
            "id": "92962324-3d1f-4cf8-993b-0c982cc95245",
            "name": "Triage"
          },
          "createdAt": "2026-09-18T10:00:00.000Z",
          "creator": {
            "id": "0c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
            "displayName": "n8n"
          }
        }
      }
    ]
  },
  "connections": {
    "Schedule Trigger": {
      "main": [
        [
          {
            "node": "Get Recent Messages",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Recent Messages": {
      "main": [
        [
          {
            "node": "Mark as Seen",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mark as Seen": {
      "main": [
        [
          {
            "node": "Markdown",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Markdown": {
      "main": [
        [
          {
            "node": "Build Jev Request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Jev Request": {
      "main": [
        [
          {
            "node": "Call Jev",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Call Jev": {
      "main": [
        [
          {
            "node": "Map Decisions",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Map Decisions": {
      "main": [
        [
          {
            "node": "Actionable?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Actionable?": {
      "main": [
        [
          {
            "node": "Any Tools To Run?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Not Actionable",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Any Tools To Run?": {
      "main": [
        [
          {
            "node": "Select Tools",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Finalise Issue",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Select Tools": {
      "main": [
        [
          {
            "node": "Loop Over Tools",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Finalise Issue": {
      "main": [
        [
          {
            "node": "Create Issue in Linear.App",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Loop Over Tools": {
      "main": [
        [
          {
            "node": "Gather Findings",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Route Tool",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Gather Findings": {
      "main": [
        [
          {
            "node": "Build Follow-up Request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route Tool": {
      "main": [
        [
          {
            "node": "Sentry",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Stripe",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Identity Admin",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Unknown Tool",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Sentry": {
      "main": [
        [
          {
            "node": "Normalise Finding",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Stripe": {
      "main": [
        [
          {
            "node": "Normalise Finding",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Identity Admin": {
      "main": [
        [
          {
            "node": "Normalise Finding",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Unknown Tool": {
      "main": [
        [
          {
            "node": "Normalise Finding",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalise Finding": {
      "main": [
        [
          {
            "node": "Loop Over Tools",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Follow-up Request": {
      "main": [
        [
          {
            "node": "Follow-up Decisions",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Follow-up Decisions": {
      "main": [
        [
          {
            "node": "Map Follow-up",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Map Follow-up": {
      "main": [
        [
          {
            "node": "Finalise Issue",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create Issue in Linear.App": {
      "main": [
        [
          {
            "node": "Record Outcome",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Record Outcome": {
      "main": [
        [
          {
            "node": "Needs Escalation?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Needs Escalation?": {
      "main": [
        [
          {
            "node": "Escalate To Human",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Filed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "binaryMode": "separate"
  },
  "versionId": "df7f2500-9205-4166-8c94-6674b144a424",
  "meta": {
    "instanceId": "b9f144fdc910a1e14e522063b576e7e28af8b611858295f590957fc8454b2836"
  },
  "nodeGroups": [],
  "id": "L73zRqo17gqqxQEy",
  "tags": []
}