{
  "name": "Email Triage with Jev",
  "nodes": [
    {
      "parameters": {
        "content": "## Before you run this\n**1. OpenRouter** — get an API key at openrouter.ai/keys and add a few dollars of credit (Jev costs about $0.04 per million input tokens, so $1 classifies roughly 20,000 emails). In n8n: **Credentials → Add → OpenRouter**, paste the key. Select it in the **Jev classify** node.\n\n**2. Gmail** — in n8n: **Credentials → Add → Gmail OAuth2** (docs.n8n.io explains the Google Cloud OAuth setup: enable the Gmail API, consent screen *Internal* for Workspace or *Production* for a gmail.com account). Google only accepts `localhost` or an HTTPS domain as the redirect URL, so if n8n runs on a LAN IP, open it as `http://localhost:5678` (SSH tunnel or port forward) just for the one-time *Sign in with Google*. Select the credential in all four Gmail nodes.\n\n**3. Create the labels** — click *Execute workflow* on **Setup labels (manual)** once per mailbox. It creates `AI`, one `AI/<category>` label per category, `AI/Needs Reply` and `AI/Review`. Safe to re-run.\n\n**4. Backfill** — click *Execute workflow* on **Backfill (manual)**. Each run classifies up to 200 unprocessed emails (Gmail search `-label:AI`); run it again until it finds nothing. Check `AI/Review` in Gmail for low-confidence calls and adjust the category descriptions if needed.\n\n**5. Go live** — activate the workflow. **New mail** polls every minute and labels each message as it arrives.\n\n**Note:** this workflow only adds labels. It never archives, moves or deletes anything, so every mistake is reversible.\n",
        "width": 500,
        "height": 1080,
        "color": 4
      },
      "name": "Read me",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -560,
        -420
      ]
    },
    {
      "parameters": {
        "content": "## Three entry points, one pipeline\n- **Setup labels (manual)**: run once.\n- **Backfill (manual)**: run until empty.\n- **New mail**: live mode, every minute.\n",
        "width": 460,
        "height": 200,
        "color": 4
      },
      "name": "Entry points",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -40,
        -420
      ]
    },
    {
      "parameters": {
        "content": "## Tuning\nJev is a *decision* model, not a chat model: it returns a probability for every category instead of text, in one call per email (about 0.4 s, output tokens are free).\n\n- **Categories** live in **Build Jev request** (`CRITERIA`). The descriptions are the whole prompt: keep them concrete and mutually exclusive, and mirror any change in the `LABELS` map in **Decide labels** and in **Label names**.\n- **Thresholds** in **Decide labels**: `REVIEW_BELOW` (confidence under this also gets `AI/Review`), `REPLY_ABOVE` (probability over this gets `AI/Needs Reply`).\n- **Body length**: `BODY_CHARS` in **Build Jev request**. 1500 characters is plenty; more costs more.\n- A failed Jev call leaves the email unlabelled so the next backfill retries it.\n",
        "width": 720,
        "height": 320,
        "color": 4
      },
      "name": "Tuning",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        460,
        -420
      ]
    },
    {
      "parameters": {},
      "name": "Setup labels (manual)",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        560
      ]
    },
    {
      "parameters": {
        "jsCode": "return [\"AI\", \"AI/People\", \"AI/Clients & Leads\", \"AI/Recruiting\", \"AI/Job Alerts\", \"AI/Cold Outreach\", \"AI/Finance & Orders\", \"AI/Security & Codes\", \"AI/Monitoring\", \"AI/Newsletters\", \"AI/Travel & Events\", \"AI/Notifications\", \"AI/Needs Reply\", \"AI/Review\"].map((name) => ({ json: { name } }));"
      },
      "name": "Label names",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        220,
        560
      ]
    },
    {
      "parameters": {
        "resource": "label",
        "operation": "create",
        "name": "={{ $json.name }}",
        "options": {}
      },
      "name": "Create label",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        460,
        560
      ],
      "onError": "continueRegularOutput",
      "credentials": {
        "gmailOAuth2": {
          "name": "Gmail account"
        }
      }
    },
    {
      "parameters": {},
      "name": "Backfill (manual)",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        0
      ]
    },
    {
      "parameters": {
        "resource": "message",
        "operation": "getAll",
        "returnAll": false,
        "limit": 200,
        "simple": false,
        "filters": {
          "q": "-label:AI -in:sent -in:drafts -in:chats"
        },
        "options": {}
      },
      "name": "Get unprocessed mail",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        220,
        0
      ],
      "credentials": {
        "gmailOAuth2": {
          "name": "Gmail account"
        }
      }
    },
    {
      "parameters": {
        "pollTimes": {
          "item": [
            {
              "mode": "everyMinute"
            }
          ]
        },
        "simple": false,
        "filters": {},
        "options": {}
      },
      "name": "New mail",
      "type": "n8n-nodes-base.gmailTrigger",
      "typeVersion": 1.2,
      "position": [
        220,
        200
      ],
      "credentials": {
        "gmailOAuth2": {
          "name": "Gmail account"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "\n// Normalises Gmail items (trigger or \"get many\", simplified or not) into one Jev request each.\nconst CRITERIA = {\n  \"person\": \"A real human writing to me personally or continuing a genuine two-way conversation (friends, family, colleagues, partners)\",\n  \"lead_client\": \"A prospective or existing client asking about my services, a project, a quote or a collaboration they initiated with specific needs\",\n  \"recruiting\": \"About MY job application or hiring process: interview invitations, assessments, recruiter replies, application status or rejection\",\n  \"job_alert\": \"Automated digest of newly posted jobs or talent-community mailings, not about a specific application of mine\",\n  \"cold_outreach\": \"Unsolicited sales or partnership pitch from a vendor trying to sell me their product, including their follow-ups\",\n  \"finance\": \"Invoices, receipts, order confirmations, shipping updates, payments, subscription billing, bank or tax documents\",\n  \"security\": \"Security alerts, login links, verification or confirmation codes, password or app-password changes\",\n  \"monitoring\": \"Automated technical reports about my systems: uptime, cron or backup status, DMARC reports, error alerts\",\n  \"newsletter\": \"Newsletters, product changelogs, marketing and promotional mailings from services I signed up to\",\n  \"travel_events\": \"Bookings, tickets, reservations, calendar invitations, event logistics\",\n  \"notification\": \"Any other automated account or service notification that fits none of the above\"\n};\nconst BODY_CHARS = 1500; // Jev bills input tokens only; the top of an email is enough to classify it\n\nconst addr = (v) => {\n  if (!v) return '';\n  if (typeof v === 'string') return v;\n  if (v.text) return v.text;\n  if (Array.isArray(v.value)) return v.value.map((a) => a.address).join(', ');\n  return '';\n};\n\nconst out = [];\nfor (const item of $input.all()) {\n  const m = item.json;\n  const labelIds = m.labelIds || (m.labels || []).map((l) => l.id) || [];\n  if (labelIds.includes('SENT') || labelIds.includes('DRAFT')) continue;\n\n  const body = (m.text || m.textAsHtml || m.snippet || '')\n    .replace(/<[^>]+>/g, ' ')\n    .replace(/\\s+/g, ' ')\n    .trim()\n    .slice(0, BODY_CHARS);\n\n  out.push({\n    json: {\n      messageId: m.id,\n      subject: m.subject || m.Subject || '',\n      from: addr(m.from || m.From),\n      jevRequest: {\n        model: 'typesafe/jev-1.13',\n        state: {\n          from: addr(m.from || m.From),\n          to: addr(m.to || m.To),\n          subject: m.subject || m.Subject || '',\n          body,\n        },\n        questions: {\n          category: {\n            type: 'choice',\n            instructions: \"Which single category best describes this email, from the recipient's point of view?\",\n            criteria: CRITERIA,\n          },\n          needs_reply: {\n            type: 'noul',\n            instructions: 'Is the recipient personally expected to reply to or act on this email (a real person is waiting, or there is a deadline)? Automated mailings and sales pitches do not count.',\n          },\n        },\n      },\n    },\n  });\n}\nreturn out;\n"
      },
      "name": "Build Jev request",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        460,
        100
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://openrouter.ai/api/alpha/decisions",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "openRouterApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.jevRequest) }}",
        "options": {
          "batching": {
            "batch": {
              "batchSize": 10,
              "batchInterval": 300
            }
          },
          "timeout": 20000
        }
      },
      "name": "Jev classify",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        700,
        0
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2000,
      "onError": "continueRegularOutput",
      "credentials": {
        "openRouterApi": {
          "name": "OpenRouter account"
        }
      }
    },
    {
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "\n// Turns Jev's probabilities into label names. Thresholds are the tuning knobs.\nconst LABELS = {\"person\": \"AI/People\", \"lead_client\": \"AI/Clients & Leads\", \"recruiting\": \"AI/Recruiting\", \"job_alert\": \"AI/Job Alerts\", \"cold_outreach\": \"AI/Cold Outreach\", \"finance\": \"AI/Finance & Orders\", \"security\": \"AI/Security & Codes\", \"monitoring\": \"AI/Monitoring\", \"newsletter\": \"AI/Newsletters\", \"travel_events\": \"AI/Travel & Events\", \"notification\": \"AI/Notifications\"};\nconst REVIEW_BELOW = 0.6; // category confidence under this also gets \"AI/Review\"\nconst REPLY_ABOVE = 0.6;  // needs_reply probability over this gets \"AI/Needs Reply\"\n\nconst src = $('Build Jev request').item.json;\nconst a = $json.answers;\nif (!a || !a.category) {\n  // Jev call failed after retries: leave the message unmarked so the next backfill run retries it\n  return { json: { messageId: src.messageId, subject: src.subject, labelNames: [], error: JSON.stringify($json).slice(0, 300) } };\n}\n\nconst names = ['AI', LABELS[a.category.choice]];\nif (a.category.confidence < REVIEW_BELOW) names.push('AI/Review');\nif (a.needs_reply && a.needs_reply.noul >= REPLY_ABOVE) names.push('AI/Needs Reply');\n\nreturn {\n  json: {\n    messageId: src.messageId,\n    from: src.from,\n    subject: src.subject,\n    category: a.category.choice,\n    confidence: a.category.confidence,\n    needsReply: a.needs_reply ? a.needs_reply.noul : null,\n    labelNames: names.filter(Boolean),\n    costUsd: $json.usage ? $json.usage.cost : null,\n  },\n};\n"
      },
      "name": "Decide labels",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        920,
        0
      ]
    },
    {
      "parameters": {
        "resource": "label",
        "operation": "getAll",
        "returnAll": true
      },
      "name": "Get Gmail labels",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        700,
        220
      ],
      "executeOnce": true,
      "credentials": {
        "gmailOAuth2": {
          "name": "Gmail account"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "\nconst labelMap = {};\nfor (const item of $input.all()) labelMap[item.json.name] = item.json.id;\nreturn [{ json: { labelMap } }];\n"
      },
      "name": "Label map",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        920,
        220
      ]
    },
    {
      "parameters": {
        "mode": "combine",
        "combineBy": "combineAll",
        "options": {}
      },
      "name": "Attach label map",
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3,
      "position": [
        1140,
        100
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "\n// Label names -> Gmail label IDs. Fails loudly if the setup workflow was never run.\nconst { labelMap, labelNames, ...rest } = $json;\nif (!labelNames.length) return { json: { ...rest, labelIds: [] } };\nconst missing = labelNames.filter((n) => !labelMap[n]);\nif (missing.length) throw new Error('Missing Gmail labels: ' + missing.join(', ') + ' - run the \"Setup labels (manual)\" trigger first');\nreturn { json: { ...rest, labelNames, labelIds: labelNames.map((n) => labelMap[n]) } };\n"
      },
      "name": "Resolve label IDs",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1360,
        100
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "leftValue": "={{ $json.labelIds.length }}",
              "rightValue": 0,
              "operator": {
                "type": "number",
                "operation": "gt"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "name": "Classified?",
      "type": "n8n-nodes-base.filter",
      "typeVersion": 2.2,
      "position": [
        1580,
        100
      ]
    },
    {
      "parameters": {
        "resource": "message",
        "operation": "addLabels",
        "messageId": "={{ $json.messageId }}",
        "labelIds": "={{ $json.labelIds }}"
      },
      "name": "Apply labels",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        1800,
        100
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2000,
      "onError": "continueRegularOutput",
      "credentials": {
        "gmailOAuth2": {
          "name": "Gmail account"
        }
      }
    }
  ],
  "connections": {
    "Setup labels (manual)": {
      "main": [
        [
          {
            "node": "Label names",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Label names": {
      "main": [
        [
          {
            "node": "Create label",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Backfill (manual)": {
      "main": [
        [
          {
            "node": "Get unprocessed mail",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get unprocessed mail": {
      "main": [
        [
          {
            "node": "Build Jev request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "New mail": {
      "main": [
        [
          {
            "node": "Build Jev request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Jev request": {
      "main": [
        [
          {
            "node": "Jev classify",
            "type": "main",
            "index": 0
          },
          {
            "node": "Get Gmail labels",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Jev classify": {
      "main": [
        [
          {
            "node": "Decide labels",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Decide labels": {
      "main": [
        [
          {
            "node": "Attach label map",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Gmail labels": {
      "main": [
        [
          {
            "node": "Label map",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Label map": {
      "main": [
        [
          {
            "node": "Attach label map",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Attach label map": {
      "main": [
        [
          {
            "node": "Resolve label IDs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Resolve label IDs": {
      "main": [
        [
          {
            "node": "Classified?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Classified?": {
      "main": [
        [
          {
            "node": "Apply labels",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "active": false
}